diff --git a/ad/manager.php b/ad/manager.php index ca659df2..1aea3215 100644 --- a/ad/manager.php +++ b/ad/manager.php @@ -12,6 +12,21 @@ class manager { + public const CONSENT_CATEGORY = 'marketing'; + + /** + * Google ad/tag scripts that support Google Consent Mode. + * + * These should run immediately so Consent Mode can control storage and + * personalization instead of blocking the ad tag entirely. + */ + protected const GOOGLE_CONSENT_AWARE_SCRIPT_SOURCE_PATTERNS = array( + '~(^|[/.])pagead2\.googlesyndication\.com/pagead/js/adsbygoogle\.js(?:[?#]|$)~i', + '~(^|[/.])securepubads\.g\.doubleclick\.net/tag/js/gpt\.js(?:[?#]|$)~i', + '~(^|[/.])www\.googletagservices\.com/tag/js/gpt\.js(?:[?#]|$)~i', + '~(^|[/.])www\.googletagmanager\.com/(?:gtag/js|gtm\.js)(?:[?#]|$)~i', + ); + /** @var \phpbb\db\driver\driver_interface */ protected $db; @@ -87,7 +102,7 @@ public function get_ads($ad_locations, $user_groups, $non_content_page = false) $user_now = $this->user->create_datetime(); $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')); - $sql = 'SELECT al.location_id, a.ad_id, a.ad_code, a.ad_centering + $sql = 'SELECT al.location_id, a.ad_id, a.ad_code, a.ad_centering, a.ad_consent FROM ' . $this->ad_locations_table . ' al LEFT JOIN ' . $this->ads_table . ' a ON (al.ad_id = a.ad_id) @@ -372,6 +387,194 @@ public function load_groups($ad_id) return $groups; } + /** + * Prepare ad code for output, applying consent-manager deferrals when enabled. + * + * @param string $ad_code Stored advertisement code + * @param bool $consent_enabled Whether marketing consent is required + * @return string + */ + public function prepare_ad_code($ad_code, $consent_enabled) + { + $ad_code = htmlspecialchars_decode($ad_code, ENT_COMPAT); + $original_ad_code = $ad_code; + + if (!$consent_enabled || $ad_code === '') + { + return $ad_code; + } + + $google_consent_aware_sources = self::get_google_consent_aware_script_sources($ad_code); + + $ad_code = preg_replace_callback('#]*)>(.*?)#is', function ($matches) use ($google_consent_aware_sources) + { + $attributes = $matches[1] ?? ''; + $content = $matches[2] ?? ''; + + if (!$this->should_defer_script_tag($attributes, $content, $google_consent_aware_sources)) + { + return $matches[0]; + } + + return 'inject_consent_attributes($attributes) . '>' . $content . ''; + }, $ad_code); + + return $ad_code ?? $original_ad_code; + } + + /** + * Determine whether a script tag is executable and should be deferred. + * + * @param string $attributes Script tag attributes + * @param string $content Script tag content + * @param array $google_consent_aware_sources Known Google loader sources in this ad block + * @return bool + */ + protected function should_defer_script_tag($attributes, $content = '', array $google_consent_aware_sources = array()) + { + if (preg_match('/\bdata-consent-category\s*=/i', $attributes)) + { + return false; + } + + if (preg_match('/\btype\s*=\s*([\'"])(.*?)\1/i', $attributes, $matches)) + { + $type = strtolower(trim(explode(';', $matches[2])[0])); + } + else + { + $type = ''; + } + + $is_executable = $type === '' + || $type === 'text/plain' + || $type === 'module' + || strpos($type, 'javascript') !== false + || strpos($type, 'ecmascript') !== false; + + if (!$is_executable) + { + return false; + } + + return !self::is_google_consent_aware_script($attributes, $content, $google_consent_aware_sources); + } + + /** + * Determine whether a script should run under Google Consent Mode. + * + * @param string $attributes Script tag attributes + * @param string $content Script tag content + * @param array $google_consent_aware_sources Known Google loader sources in this ad block + * @return bool + */ + public static function is_google_consent_aware_script($attributes, $content, array $google_consent_aware_sources) + { + $source = self::extract_script_source($attributes); + if ($source !== '') + { + return isset($google_consent_aware_sources[self::normalize_script_source($source)]); + } + + return !empty($google_consent_aware_sources) + && preg_match('/\b(?:adsbygoogle|googletag|gtag|dataLayer)\b/', $content); + } + + /** + * Return known Google Consent Mode-aware loader sources in an ad block. + * + * @param string $ad_code Advertisement code + * @return array + */ + public static function get_google_consent_aware_script_sources($ad_code) + { + $sources = array(); + + if (!preg_match_all('#]*)>#is', $ad_code, $matches)) + { + return $sources; + } + + foreach ($matches[1] as $attributes) + { + $source = self::extract_script_source($attributes); + if ($source !== '' && self::is_google_consent_aware_script_source($source)) + { + $sources[self::normalize_script_source($source)] = true; + } + } + + return $sources; + } + + /** + * Extract the src attribute from a script tag attribute string. + * + * @param string $attributes Script tag attributes + * @return string + */ + public static function extract_script_source($attributes) + { + return preg_match('/\bsrc\s*=\s*([\'"])(.*?)\1/i', $attributes, $matches) ? $matches[2] : ''; + } + + /** + * Check whether a script source is a known Google Consent Mode-aware loader. + * + * @param string $source Script source URL + * @return bool + */ + protected static function is_google_consent_aware_script_source($source) + { + $source = self::normalize_script_source($source); + + foreach (self::GOOGLE_CONSENT_AWARE_SCRIPT_SOURCE_PATTERNS as $pattern) + { + if (preg_match($pattern, $source)) + { + return true; + } + } + + return false; + } + + /** + * Normalize a script source before comparing against allowlisted loaders. + * + * @param string $source Script source URL + * @return string + */ + protected static function normalize_script_source($source) + { + return preg_replace('#^//#', 'https://', trim($source)); + } + + /** + * Replace script tag attributes with consent-aware placeholders. + * + * @param string $attributes Script tag attributes + * @return string + */ + protected function inject_consent_attributes($attributes) + { + if (preg_match('/\btype\s*=\s*([\'"])(.*?)\1/i', $attributes)) + { + $attributes = preg_replace('/\btype\s*=\s*([\'"])(.*?)\1/i', 'type="text/plain"', $attributes, 1); + } + else + { + $attributes .= ' type="text/plain"'; + } + + if (!preg_match('/\bdata-consent-category\s*=/i', $attributes)) + { + $attributes .= ' data-consent-category="' . self::CONSENT_CATEGORY . '"'; + } + + return $attributes; + } + /** * Make sure only necessary data make their way to SQL query * @@ -393,6 +596,7 @@ protected function intersect_ad_data($data) 'ad_owner' => '', 'ad_content_only' => '', 'ad_centering' => '', + 'ad_consent' => '', ]); } diff --git a/adm/style/manage_ads.html b/adm/style/manage_ads.html index 8a486d1a..80da625a 100644 --- a/adm/style/manage_ads.html +++ b/adm/style/manage_ads.html @@ -117,6 +117,15 @@

{{ lang('WARNING') }}

{{ lang('AD_OPTIONS') }} + {% if S_ADS_CONSENTMANAGER_AVAILABLE %} +
+

{{ lang('AD_CONSENT_EXPLAIN') }}
+
+
+
+ {% else %} + + {% endif %}

{{ lang('AD_OWNER_EXPLAIN') }}
diff --git a/analyser/manager.php b/analyser/manager.php index 0611e988..52368b3c 100644 --- a/analyser/manager.php +++ b/analyser/manager.php @@ -39,13 +39,14 @@ public function __construct($tests, \phpbb\template\template $template, \phpbb\l * Test the ad code for potential problems. * * @param string $ad_code Advertisement code + * @param array $context Optional form context */ - public function run($ad_code) + public function run($ad_code, array $context = array()) { $results = array(); foreach ($this->tests as $test) { - $result = $test->run($ad_code); + $result = $test->run($ad_code, $context); if ($result !== false) { $results[] = $result; diff --git a/analyser/test/alert.php b/analyser/test/alert.php index e15be5f3..e0a3524a 100644 --- a/analyser/test/alert.php +++ b/analyser/test/alert.php @@ -20,7 +20,7 @@ class alert implements test_interface * There is no reason why ad would trigger alert, so it's * categorized as warning. */ - public function run($ad_code) + public function run($ad_code, array $context = array()) { if (preg_match('/alert\s*\(/U', $ad_code)) { diff --git a/analyser/test/iframe.php b/analyser/test/iframe.php new file mode 100644 index 00000000..e20b3b32 --- /dev/null +++ b/analyser/test/iframe.php @@ -0,0 +1,34 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\ads\analyser\test; + +class iframe implements test_interface +{ + /** + * {@inheritDoc} + * + * Iframes test. + * This test looks for iframe tags with src attributes. Such scripts could introduce + * external trackers and data collectors that could require user consent. + */ + public function run($ad_code, array $context = array()) + { + if (preg_match('/<iframe(?>(?!>).)*?(?<=\s|")src\s*=\s*".*?>/is', $ad_code)) + { + return array( + 'severity' => 'notice', + 'message' => 'IFRAME_USAGE', + ); + } + + return false; + } +} diff --git a/analyser/test/location_href.php b/analyser/test/location_href.php index 8909aa9a..026c1c46 100644 --- a/analyser/test/location_href.php +++ b/analyser/test/location_href.php @@ -20,7 +20,7 @@ class location_href implements test_interface * There is no reason why ad would redirect user to another page, * so it's categorized as warning. */ - public function run($ad_code) + public function run($ad_code, array $context = array()) { if (preg_match('/location\.href(\s)*=/U', $ad_code)) { diff --git a/analyser/test/marketing_consent.php b/analyser/test/marketing_consent.php new file mode 100644 index 00000000..62832291 --- /dev/null +++ b/analyser/test/marketing_consent.php @@ -0,0 +1,171 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\ads\analyser\test; + +class marketing_consent implements test_interface +{ + /** + * Common advertising and marketing script hosts. + * + * Keep this list script-focused. Ads extension only defers script tags, so + * host hints should not expand warnings to iframe-only or image-only embeds. + */ + protected const MARKETING_HOST_PATTERNS = array( + '/(^|[\/.])partner\.googleadservices\.com(?=[:\/]|$)/i', + '/(^|[\/.])googleads\.g\.doubleclick\.net(?=[:\/]|$)/i', + '/(^|[\/.])c\.amazon-adsystem\.com(?=[:\/]|$)/i', + '/(^|[\/.])aax\.amazon-adsystem\.com(?=[:\/]|$)/i', + '/(^|[\/.])trc\.taboola\.com(?=[:\/]|$)/i', + '/(^|[\/.])cdn\.taboola\.com(?=[:\/]|$)/i', + '/(^|[\/.])widgets\.outbrain\.com(?=[:\/]|$)/i', + '/(^|[\/.])odr\.outbrain\.com(?=[:\/]|$)/i', + '/(^|[\/.])static\.criteo\.net(?=[:\/]|$)/i', + '/(^|[\/.])gum\.criteo\.com(?=[:\/]|$)/i', + '/(^|[\/.])secure\.adnxs\.com(?=[:\/]|$)/i', + '/(^|[\/.])ib\.adnxs\.com(?=[:\/]|$)/i', + ); + + /** @var \phpbb\config\config */ + protected $config; + + /** + * @param \phpbb\config\config $config Config object + */ + public function __construct(\phpbb\config\config $config) + { + $this->config = $config; + } + + /** + * {@inheritDoc} + * + * Suggest enabling Require marketing consent when executable script tags are + * present, Consent Manager marketing is available, and the ad-level consent + * toggle is currently disabled. + */ + public function run($ad_code, array $context = array()) + { + if (!$this->config->offsetExists('consentmanager_marketing_enabled') + || empty($this->config['consentmanager_marketing_enabled']) + || !isset($context['ad_consent']) + || !empty($context['ad_consent'])) + { + return false; + } + + $decoded = htmlspecialchars_decode($ad_code, ENT_COMPAT); + $message = $this->get_recommendation_message($decoded); + if ($message === false) + { + return false; + } + + return array( + 'severity' => 'notice', + 'message' => $message, + ); + } + + /** + * Get consent recommendation message for ad code, if any. + * + * @param string $ad_code Advertisement code + * @return string|false + */ + protected function get_recommendation_message($ad_code) + { + if (!preg_match_all('#]*)>(.*?)#is', $ad_code, $matches)) + { + return false; + } + + $google_consent_aware_sources = \phpbb\ads\ad\manager::get_google_consent_aware_script_sources($ad_code); + + foreach ($matches[1] as $index => $attributes) + { + $content = $matches[2][$index] ?? ''; + if (!$this->should_flag_script_tag($attributes)) + { + continue; + } + + if (\phpbb\ads\ad\manager::is_google_consent_aware_script($attributes, $content, $google_consent_aware_sources)) + { + continue; + } + + if ($this->contains_marketing_host_hint($attributes, $content)) + { + return 'MARKETING_CONSENT_VENDOR_RECOMMENDED'; + } + + return 'MARKETING_CONSENT_RECOMMENDED'; + } + + return false; + } + + /** + * Check for known advertising vendor hints inside script markup or content. + * + * @param string $attributes Script tag attributes + * @param string $content Script tag content + * @return bool + */ + protected function contains_marketing_host_hint($attributes, $content) + { + $haystacks = array($attributes, $content); + + $source = \phpbb\ads\ad\manager::extract_script_source($attributes); + if ($source !== '') + { + $haystacks[] = $source; + } + + foreach ($haystacks as $haystack) + { + foreach (self::MARKETING_HOST_PATTERNS as $pattern) + { + if (preg_match($pattern, $haystack)) + { + return true; + } + } + } + + return false; + } + + /** + * Mirror ads defer logic closely enough to avoid flagging inert script types. + * + * @param string $attributes Script tag attributes + * @return bool + */ + protected function should_flag_script_tag($attributes) + { + if (preg_match('/\bdata-consent-category\s*=/i', $attributes)) + { + return false; + } + + if (!preg_match('/\btype\s*=\s*([\'"])(.*?)\1/i', $attributes, $matches)) + { + return true; + } + + $type = strtolower(trim(explode(';', $matches[2])[0])); + return $type === '' + || $type === 'module' + || strpos($type, 'javascript') !== false + || strpos($type, 'ecmascript') !== false; + } +} diff --git a/analyser/test/script_without_async.php b/analyser/test/script_without_async.php index 2ab7e7b8..3b6828bb 100644 --- a/analyser/test/script_without_async.php +++ b/analyser/test/script_without_async.php @@ -20,7 +20,7 @@ class script_without_async implements test_interface * to load itself asynchronously. Such scripts slow down page rendering * time and should be made asynchronous. */ - public function run($ad_code) + public function run($ad_code, array $context = array()) { if (preg_match_all('/<script(.*)src(.*)>/U', $ad_code, $matches)) { diff --git a/analyser/test/test_interface.php b/analyser/test/test_interface.php index 03b235d8..8445d20a 100644 --- a/analyser/test/test_interface.php +++ b/analyser/test/test_interface.php @@ -19,7 +19,8 @@ interface test_interface * Test ad code for potential problems. * * @param string $ad_code Advertisement code + * @param array $context Optional form context * @return mixed List of notices and warnings or false when there are none. */ - public function run($ad_code); + public function run($ad_code, array $context = array()); } diff --git a/analyser/test/untrusted_connection.php b/analyser/test/untrusted_connection.php index c13194bc..7b9723fe 100644 --- a/analyser/test/untrusted_connection.php +++ b/analyser/test/untrusted_connection.php @@ -32,7 +32,7 @@ public function __construct(\phpbb\request\request $request) * When board runs on HTTPS and ad tries to load a file from * HTTP source, browser throws a warning. We should prevent that. */ - public function run($ad_code) + public function run($ad_code, array $context = array()) { $is_https = $this->request->server('HTTPS', false); if ($is_https && preg_match('/http[^s]/', $ad_code)) diff --git a/config/analyser.yml b/config/analyser.yml index 65ce08c3..82fdafa1 100644 --- a/config/analyser.yml +++ b/config/analyser.yml @@ -29,9 +29,22 @@ services: tags: - { name: phpbb.ads.analyser.test } + phpbb.ads.analyser.test.marketing_consent: + class: phpbb\ads\analyser\test\marketing_consent + arguments: + - '@config' + tags: + - { name: phpbb.ads.analyser.test } + phpbb.ads.analyser.test.untrusted_connection: class: phpbb\ads\analyser\test\untrusted_connection arguments: - '@request' tags: - { name: phpbb.ads.analyser.test } + + phpbb.ads.analyser.test.iframe: + class: phpbb\ads\analyser\test\iframe + tags: + - { name: phpbb.ads.analyser.test } + diff --git a/controller/admin_controller.php b/controller/admin_controller.php index 73e3e111..9fcd1161 100644 --- a/controller/admin_controller.php +++ b/controller/admin_controller.php @@ -88,7 +88,10 @@ public function __construct(\phpbb\template\template $template, \phpbb\language\ $this->language->add_lang('posting'); // Used by banner_upload() file errors $this->language->add_lang('acp', 'phpbb/ads'); - $this->template->assign_var('S_PHPBB_ADS', true); + $this->template->assign_vars([ + 'S_PHPBB_ADS' => true, + 'S_ADS_CONSENTMANAGER_AVAILABLE' => $this->is_consent_manager_available() + ]); if (!class_exists('auth_admin')) { @@ -427,13 +430,13 @@ protected function upload_banner() /** * Submit action "analyse_ad_code". - * Upload banner and append it to the ad code. + * Analyse submitted ad code with current form state. * * @return void */ protected function analyse_ad_code() { - $this->analyser->run($this->data['ad_code']); + $this->analyser->run($this->data['ad_code'], $this->data); } /** @@ -526,4 +529,15 @@ protected function toggle_permission($user_id) $this->auth_admin->acl_set('user', 0, $user_id, array('u_phpbb_ads' => (int) $has_ads)); } } + + /** + * Check whether Consent Manager's marketing category is available. + * + * @return bool + */ + protected function is_consent_manager_available() + { + return $this->config->offsetExists('consentmanager_marketing_enabled') + && (bool) $this->config['consentmanager_marketing_enabled']; + } } diff --git a/controller/admin_input.php b/controller/admin_input.php index 77c88c4d..6ae1fe0f 100644 --- a/controller/admin_input.php +++ b/controller/admin_input.php @@ -97,6 +97,7 @@ public function get_form_data() 'ad_owner' => $this->request->variable('ad_owner', '', true), 'ad_groups' => $this->request->variable('ad_groups', array(0)), 'ad_centering' => $this->request->variable('ad_centering', true), + 'ad_consent' => $this->request->variable('ad_consent', 1), ); // Validate form key diff --git a/controller/helper.php b/controller/helper.php index 81c64f1a..113260fc 100644 --- a/controller/helper.php +++ b/controller/helper.php @@ -103,6 +103,7 @@ public function assign_data($data, $errors) 'AD_CLICKS_LIMIT' => $data['ad_clicks_limit'], 'AD_OWNER' => $this->get_username($data['ad_owner']), 'AD_CENTERING' => $data['ad_centering'], + 'AD_CONSENT' => $data['ad_consent'] ?? 1, )); } diff --git a/event/main_listener.php b/event/main_listener.php index 30e3f7bf..c8b64c55 100644 --- a/event/main_listener.php +++ b/event/main_listener.php @@ -64,6 +64,7 @@ public static function getSubscribedEvents() 'core.adm_page_header_after' => 'disable_xss_protection', 'core.group_add_user_after' => 'destroy_user_group_cache', 'core.group_delete_user_after' => 'destroy_user_group_cache', + 'phpbb.consentmanager.collect_registrations' => 'register_ads', ); } @@ -133,16 +134,19 @@ public function setup_ads() { // check for the existence of 'MESSAGE_TEXT', which signals it's an error page. $non_content_page = $this->template->retrieve_var('MESSAGE_TEXT') || $this->is_non_content_page(); + $consent_enabled = (bool) $this->template->retrieve_var('S_CONSENTMANAGER_MARKETING_ENABLED'); $location_ids = $this->location_manager->get_all_location_ids(); $user_groups = $this->manager->load_memberships($this->user->data['user_id']); $ad_ids = array(); + $ads = $this->manager->get_ads($location_ids, $user_groups, $non_content_page); - foreach ($this->manager->get_ads($location_ids, $user_groups, $non_content_page) as $row) + foreach ($ads as $row) { $ad_ids[] = $row['ad_id']; + $ad_consent_enabled = $consent_enabled && (bool) ($row['ad_consent'] ?? true); $this->template->assign_vars(array( - 'AD_' . strtoupper($row['location_id']) => htmlspecialchars_decode($row['ad_code'], ENT_COMPAT), + 'AD_' . strtoupper($row['location_id']) => $this->manager->prepare_ad_code($row['ad_code'], $ad_consent_enabled), 'AD_' . strtoupper($row['location_id']) . '_ID' => (int) $row['ad_id'], 'AD_' . strtoupper($row['location_id']) . '_CENTER' => (bool) $row['ad_centering'], )); @@ -302,4 +306,19 @@ public function append_agreement() $this->template->append_var('AGREEMENT_TEXT', $this->language->lang('PHPBB_ADS_PRIVACY_POLICY', $this->config['sitename'])); } + + /** + * Register the advertisement extension with Consent Manager. + * + * @param \phpbb\event\data|array $event The event object or event data + * @return void + */ + public function register_ads($event) + { + $event['consent_manager']->register('phpbb.ads', array( + 'label' => $this->language->lang('PHPBB_ADS_CONSENT_LABEL'), + 'category' => \phpbb\ads\ad\manager::CONSENT_CATEGORY, + 'description' => $this->language->lang('PHPBB_ADS_CONSENT_DESCRIPTION'), + )); + } } diff --git a/language/en/acp.php b/language/en/acp.php index 5af42e27..55ceea8d 100644 --- a/language/en/acp.php +++ b/language/en/acp.php @@ -56,6 +56,8 @@ 'AD_CLICKS' => 'Clicks', 'AD_CLICKS_LIMIT' => 'Clicks Limit', 'AD_CLICKS_LIMIT_EXPLAIN' => 'Set the maximum number of times the advertisement will be clicked, after which the advertisement will no longer be displayed. Set 0 for unlimited clicks.', + 'AD_CONSENT' => 'Require marketing consent', + 'AD_CONSENT_EXPLAIN' => 'Set to Yes to defer script tags in this advertisement until the visitor grants marketing consent in Privacy Settings. Set to No only for ad code that does not load marketing, tracking, cookies, profiling, or other consent-controlled resources.

Note: This setting has no effect on supported Google AdSense or Google Publisher Tag (GPT) code. Consent Manager automatically manages consent for Google Ads through Google Consent Mode.', 'AD_START_DATE' => 'Start Date', 'AD_START_DATE_EXPLAIN' => 'Set the date when the advertisement can begin displaying (starting at 00:00). The ad must still be manually enabled to appear. If no date is set, the ad can display immediately once enabled.', 'AD_END_DATE' => 'End Date', @@ -99,8 +101,11 @@ // Analyser tests 'UNSECURE_CONNECTION' => 'Mixed Content
Your board runs on a secure HTTPS connection; however, the advertisement code is attempting to load content from an insecure HTTP connection. This can cause browsers to generate a “Mixed Content” warning to let users know that the page contains insecure resources.', 'SCRIPT_WITHOUT_ASYNC' => 'Non-asynchronous javascript
This advertisement code loads JavaScript code in a non-asynchronous way. This means it will block any other JavaScript from loading until it has completed loading, which can affect page load performance. Use of the async attribute can speed up the page load.', + 'MARKETING_CONSENT_RECOMMENDED' => 'Require marketing consent
This advertisement contains executable <script> tags. If this ad loads marketing, tracking, cookies, or other consent-controlled resources, enable Require marketing consent below for this ad so its scripts are deferred until the visitor allows marketing in Privacy Settings.', + 'MARKETING_CONSENT_VENDOR_RECOMMENDED' => 'Known ad vendor detected
This advertisement contains executable <script> tags from a known advertising or marketing vendor. Enable Require marketing consent below for this ad so its scripts are deferred until the visitor allows marketing in Privacy Settings.', 'ALERT_USAGE' => 'Usage of alert()
Your code uses the alert() function which is not a good practice and can distract users. Some browsers may also block page load and display additional warnings to the user.', - 'LOCATION_CHANGE' => 'Redirection
Your code appears it can redirect a user to another page or site. Redirects can sometimes send users to unintended, often malicious, destinations. Please verify the integrity of your advertisement codeadvertisement code’s redirection destination.', + 'LOCATION_CHANGE' => 'Redirection
Your code appears it can redirect a user to another page or site. Redirects can sometimes send users to unintended, often malicious, destinations. Please verify the integrity of your advertisement code’s redirection destination.', + 'IFRAME_USAGE' => 'Usage of <iframe>
Your code contains HTML-encoded <iframe> tags. Because iframes can introduce third-party tracking or data collection, please review this advertisement snippet to ensure it complies with your user privacy policies.', // Template location categories 'CAT_TOP_OF_PAGE' => 'Top of page', diff --git a/language/en/common.php b/language/en/common.php index ebe45c83..fa60493f 100644 --- a/language/en/common.php +++ b/language/en/common.php @@ -26,6 +26,8 @@ ], 'ADVERTISEMENT' => 'Advertisement', 'HIDE_AD' => 'Hide advertisement', + 'PHPBB_ADS_CONSENT_LABEL' => 'Advertisements', + 'PHPBB_ADS_CONSENT_DESCRIPTION' => 'Advertising features that may use cookies or similar technologies to collect data.', 'VISUAL_DEMO' => 'Visual demo for ad locations is active', 'DISABLE_VISUAL_DEMO' => 'Click to disable visual demo', diff --git a/migrations/v20x/m6_ad_consent_option.php b/migrations/v20x/m6_ad_consent_option.php new file mode 100644 index 00000000..396ec011 --- /dev/null +++ b/migrations/v20x/m6_ad_consent_option.php @@ -0,0 +1,64 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\ads\migrations\v20x; + +class m6_ad_consent_option extends \phpbb\db\migration\migration +{ + /** + * {@inheritDoc} + */ + public function effectively_installed() + { + return $this->db_tools->sql_column_exists($this->table_prefix . 'ads', 'ad_consent'); + } + + /** + * {@inheritDoc} + */ + public static function depends_on() + { + return array( + '\phpbb\ads\migrations\v20x\m5_add_privacy_setting', + ); + } + + /** + * Add the per-ad consent option to ads table. + * + * @return array Array of table schema + */ + public function update_schema() + { + return array( + 'add_columns' => array( + $this->table_prefix . 'ads' => array( + 'ad_consent' => array('BOOL', 1), + ), + ), + ); + } + + /** + * Drop the per-ad consent option from ads table. + * + * @return array Array of table schema + */ + public function revert_schema() + { + return array( + 'drop_columns' => array( + $this->table_prefix . 'ads' => array( + 'ad_consent', + ), + ), + ); + } +} diff --git a/tests/ad/get_ad_test.php b/tests/ad/get_ad_test.php index 15e61c4a..46071965 100644 --- a/tests/ad/get_ad_test.php +++ b/tests/ad/get_ad_test.php @@ -36,6 +36,7 @@ public function get_ad_data() 'ad_owner' => '2', 'ad_content_only' => '0', 'ad_centering' => '1', + 'ad_consent' => '1', )), array(0, array()), ); diff --git a/tests/ad/get_ads_test.php b/tests/ad/get_ads_test.php index 88fced45..c628c2a1 100644 --- a/tests/ad/get_ads_test.php +++ b/tests/ad/get_ads_test.php @@ -21,13 +21,13 @@ public function get_ads_data() { return array( array(array('after_profile'), array( - array('location_id' => 'after_profile', 'ad_code' => 'Ad Code #1', 'ad_id' => '1', 'ad_centering' => '1'), + array('location_id' => 'after_profile', 'ad_code' => 'Ad Code #1', 'ad_id' => '1', 'ad_centering' => '1', 'ad_consent' => '1'), ), false), array(array('before_profile'), array( - array('location_id' => 'before_profile', 'ad_code' => 'Ad Code #4', 'ad_id' => '4', 'ad_centering' => '1'), + array('location_id' => 'before_profile', 'ad_code' => 'Ad Code #4', 'ad_id' => '4', 'ad_centering' => '1', 'ad_consent' => '1'), ), false), array(array('below_footer'), array( - array('location_id' => 'below_footer', 'ad_code' => 'Ad Code #7', 'ad_id' => '7', 'ad_centering' => '1'), + array('location_id' => 'below_footer', 'ad_code' => 'Ad Code #7', 'ad_id' => '7', 'ad_centering' => '1', 'ad_consent' => '1'), ), false), array(array('below_footer'), array(), true), array(array('foo_bar'), array(), false), diff --git a/tests/ad/prepare_ad_code_test.php b/tests/ad/prepare_ad_code_test.php new file mode 100644 index 00000000..f49864c3 --- /dev/null +++ b/tests/ad/prepare_ad_code_test.php @@ -0,0 +1,152 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\ads\tests\ad; + +class prepare_ad_code_test extends ad_base +{ + public function test_consent_category_constant() + { + self::assertSame('marketing', \phpbb\ads\ad\manager::CONSENT_CATEGORY); + } + + public function test_returns_decoded_code_when_consent_disabled() + { + $raw = htmlspecialchars('', ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, false); + self::assertSame('', $result); + self::assertStringNotContainsString('type="text/plain"', $result); + } + + public function test_returns_empty_string_unchanged() + { + self::assertSame('', $this->get_manager()->prepare_ad_code('', true)); + } + + public function executable_script_type_data() + { + return [ + 'normal script' => [ + '', + '', + ], + 'empty type' => [ + '', + '', + ], + 'text/plain type' => [ + '', + '', + ], + 'module type' => [ + '', + '', + ], + 'javascript type with charset' => [ + '', + '', + ], + 'ecmascript type' => [ + '', + '', + ], + ]; + } + + /** + * @dataProvider executable_script_type_data + */ + public function test_defers_executable_script_types($input, $expected) + { + $raw = htmlspecialchars($input, ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, true); + self::assertSame($expected, $result); + } + + public function test_preserves_non_executable_script_type() + { + $raw = htmlspecialchars('', ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, true); + self::assertSame('', $result); + } + + public function test_does_not_double_wrap_already_tagged_script() + { + $script = ''; + $raw = htmlspecialchars($script, ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, true); + self::assertSame($script, $result); + self::assertSame(1, substr_count($result, 'data-consent-category=')); + } + + public function google_consent_aware_script_data() + { + return [ + 'adsense loader' => [ + '', + ], + 'gpt loader' => [ + '', + ], + 'gtag loader' => [ + '', + ], + 'gtm loader' => [ + '', + ], + ]; + } + + /** + * @dataProvider google_consent_aware_script_data + */ + public function test_does_not_defer_google_consent_aware_loaders($script) + { + $raw = htmlspecialchars($script, ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, true); + self::assertSame($script, $result); + } + + public function test_does_not_defer_adsense_inline_script_when_adsense_loader_is_present() + { + $script = ''; + $raw = htmlspecialchars($script, ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, true); + self::assertSame($script, $result); + } + + public function test_does_not_defer_gpt_inline_script_when_gpt_loader_is_present() + { + $script = ''; + $raw = htmlspecialchars($script, ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, true); + self::assertSame($script, $result); + } + + public function test_defers_google_named_inline_script_without_google_loader() + { + $raw = htmlspecialchars('', ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, true); + self::assertSame('', $result); + } + + public function test_google_consent_aware_source_lookup_returns_empty_without_script_tags() + { + self::assertSame(array(), \phpbb\ads\ad\manager::get_google_consent_aware_script_sources('
No scripts
')); + } + + public function test_non_script_html_is_preserved() + { + $raw = htmlspecialchars('
Ad
', ENT_COMPAT); + $result = $this->get_manager()->prepare_ad_code($raw, true); + self::assertStringContainsString('', $result); + self::assertStringNotContainsString('type="text/plain"', $result); + } +} diff --git a/tests/analyser/analyser_base.php b/tests/analyser/analyser_base.php index bdde8299..13cfadf8 100644 --- a/tests/analyser/analyser_base.php +++ b/tests/analyser/analyser_base.php @@ -24,6 +24,9 @@ class analyser_base extends \phpbb_test_case /** @var \phpbb\language\language */ protected $lang; + /** @var \phpbb\config\config */ + protected $config; + protected static function setup_extensions() { return array('phpbb/ads'); @@ -47,13 +50,18 @@ protected function setUp(): void ->disableOriginalConstructor() ->getMock(); $this->lang = new \phpbb\language\language($lang_loader); + $this->config = new \phpbb\config\config(array( + 'consentmanager_marketing_enabled' => 0, + )); // Tests $tests = array( 'alert', 'location_href', 'script_without_async', + 'marketing_consent', 'untrusted_connection', + 'iframe', ); $analyser_tests = array(); foreach ($tests as $test) @@ -63,6 +71,10 @@ protected function setUp(): void { $analyser_tests['phpbb.ads.analyser.test.' . $test] = new $class($this->request); } + else if ($test === 'marketing_consent') + { + $analyser_tests['phpbb.ads.analyser.test.' . $test] = new $class($this->config); + } else { $analyser_tests['phpbb.ads.analyser.test.' . $test] = new $class(); diff --git a/tests/analyser/run_test.php b/tests/analyser/run_test.php index 6f67f46d..84a27e64 100644 --- a/tests/analyser/run_test.php +++ b/tests/analyser/run_test.php @@ -20,64 +20,64 @@ class run_test extends analyser_base public function run_data() { return array( - array('<script async>alert()</script>', false, array( + 'warns on alert call' => array('<script async>alert()</script>', false, array(), array( array( 'severity' => 'warning', 'lang_key' => 'ALERT_USAGE', ), )), - array('<script async>alert ()</script>', false, array( + 'warns on spaced alert call' => array('<script async>alert ()</script>', false, array(), array( array( 'severity' => 'warning', 'lang_key' => 'ALERT_USAGE', ), )), - array('<script async>window.location.href = "new url"</script>', false, array( + 'warns on location href assignment' => array('<script async>window.location.href = "new url"</script>', false, array(), array( array( 'severity' => 'warning', 'lang_key' => 'LOCATION_CHANGE', ), )), - array('<script async>window.location.href= "new url"</script>', false, array( + 'warns on compact location href assignment' => array('<script async>window.location.href= "new url"</script>', false, array(), array( array( 'severity' => 'warning', 'lang_key' => 'LOCATION_CHANGE', ), )), - array('<script></script>', false, array()), - array('<script src="script src"></script>', false, array( + 'allows empty script without src' => array('<script></script>', false, array(), array()), + 'notices script without async' => array('<script src="script src"></script>', false, array(), array( array( 'severity' => 'notice', 'lang_key' => 'SCRIPT_WITHOUT_ASYNC', ), )), - array('<script src="script src"></script><script src="another script src"></script>', false, array( + 'notices first of multiple scripts without async' => array('<script src="script src"></script><script src="another script src"></script>', false, array(), array( array( 'severity' => 'notice', 'lang_key' => 'SCRIPT_WITHOUT_ASYNC', ), )), - array('<script async src="script src"></script><script src="another script src"></script>', false, array( + 'notices second script without async' => array('<script async src="script src"></script><script src="another script src"></script>', false, array(), array( array( 'severity' => 'notice', 'lang_key' => 'SCRIPT_WITHOUT_ASYNC', ), )), - array('<script src="script src"></script><script async src="another script src"></script>', false, array( + 'notices first script without async before async script' => array('<script src="script src"></script><script async src="another script src"></script>', false, array(), array( array( 'severity' => 'notice', 'lang_key' => 'SCRIPT_WITHOUT_ASYNC', ), )), - array('<script async src="http://some.url"></script>', false, array()), - array('<script async src="https://some.url"></script>', true, array()), - array('<script async src="http://some.url"></script>', true, array( + 'allows http script on http page' => array('<script async src="http://some.url"></script>', false, array(), array()), + 'allows https script on https page' => array('<script async src="https://some.url"></script>', true, array(), array()), + 'warns on http script on https page' => array('<script async src="http://some.url"></script>', true, array(), array( array( 'severity' => 'warning', 'lang_key' => 'UNSECURE_CONNECTION', ), )), - array('<script src="http://some.url"></script><script>alert("e");window.location.href="new url"</script>', true, array( + 'collects multiple analyser warnings' => array('<script src="http://some.url"></script><script>alert("e");window.location.href="new url"</script>', true, array(), array( array( 'severity' => 'warning', 'lang_key' => 'ALERT_USAGE', @@ -95,6 +95,72 @@ public function run_data() 'lang_key' => 'UNSECURE_CONNECTION', ), )), + 'notices iframe usage' => array('<iframe src="https://some.url" width="640" height="360" allowfullscreen></iframe>', false, array(), array( + array( + 'severity' => 'notice', + 'lang_key' => 'IFRAME_USAGE', + ), + )), + 'allows consent-aware iframe placeholder' => array('<iframe data-consent-src="https://some.url" width="640" height="360" allowfullscreen></iframe>', false, array(), array()), + 'recommends marketing consent for generic ad script' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array( + array( + 'severity' => 'notice', + 'lang_key' => 'MARKETING_CONSENT_RECOMMENDED', + ), + )), + 'recommends marketing consent for inline cookie script' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array( + array( + 'severity' => 'notice', + 'lang_key' => 'MARKETING_CONSENT_RECOMMENDED', + ), + )), + 'recommends marketing consent for known non-Google vendor script' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array( + array( + 'severity' => 'notice', + 'lang_key' => 'MARKETING_CONSENT_VENDOR_RECOMMENDED', + ), + )), + 'allows AdSense loader under Google Consent Mode' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array()), + 'allows full AdSense snippet under Google Consent Mode' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array()), + 'allows GPT loader under Google Consent Mode' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array()), + 'allows non-executable json script' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array()), + 'allows Google ad iframe because marketing consent analyser only handles scripts' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array()), + 'allows generic ad script when ad consent is already enabled' => array('', false, array( + 'ad_consent' => 1, + 'consentmanager_marketing_enabled' => 1, + ), array()), + 'allows generic ad script when Consent Manager marketing category is disabled' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 0, + ), array()), + 'allows already consent-tagged script' => array('', false, array( + 'ad_consent' => 0, + 'consentmanager_marketing_enabled' => 1, + ), array()), ); } @@ -103,9 +169,10 @@ public function run_data() * * @dataProvider run_data */ - public function test_run($ad_code, $is_https, $expected) + public function test_run($ad_code, $is_https, $context, $expected) { $manager = $this->get_manager(); + $this->config['consentmanager_marketing_enabled'] = $context['consentmanager_marketing_enabled'] ?? 0; $this->request ->method('server') @@ -132,6 +199,6 @@ public function test_run($ad_code, $is_https, $expected) ->method('assign_block_vars'); } - $manager->run($ad_code); + $manager->run($ad_code, $context); } } diff --git a/tests/controller/admin_controller_test.php b/tests/controller/admin_controller_test.php index 1bead390..391f2bc0 100644 --- a/tests/controller/admin_controller_test.php +++ b/tests/controller/admin_controller_test.php @@ -479,7 +479,7 @@ public function test_action_add_analyse_ad_code() $this->analyser->expects(self::once()) ->method('run') - ->with($data['ad_code']); + ->with($data['ad_code'], $data); $this->input->expects(self::once()) ->method('get_errors') diff --git a/tests/controller/admin_input_test.php b/tests/controller/admin_input_test.php index 39f53d33..d01c10d8 100644 --- a/tests/controller/admin_input_test.php +++ b/tests/controller/admin_input_test.php @@ -107,22 +107,22 @@ public function get_input_controller() public function get_form_data_data() { return array( - array(false, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, '', [], false], 0, ['FORM_INVALID']), - array(true, ['', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, '', [], false], 0, ['AD_NAME_REQUIRED']), - array(true, [str_repeat('a', 256), 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, '', [], false], 0, ['AD_NAME_TOO_LONG']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code with emoji 😀', 0, '', '', '', 5, 0, 0, 0, '', [], false], 0, ['AD_CODE_ILLEGAL_CHARS']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', 'blah', '', 5, 0, 0, 0, '', [], false], 0, ['AD_START_DATE_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', 'blah', 5, 0, 0, 0, '', [], false], 0, ['AD_END_DATE_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '1970-01-01', '', 5, 0, 0, 0, '', [], false], 0, ['AD_START_DATE_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '1970-01-01', 5, 0, 0, 0, '', [], false], 0, ['AD_END_DATE_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '2060-01-01', '2050-01-01', 5, 0, 0, 0, '', [], false], 0, ['END_DATE_TOO_SOON']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 0, 0, 0, 0, '', [], false], 0, ['AD_PRIORITY_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 11, 0, 0, 0, '', [], false], 0, ['AD_PRIORITY_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, -1, 0, '', [], false], 0, ['AD_VIEWS_LIMIT_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, -1, '', [], false], 0, ['AD_CLICKS_LIMIT_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, 'adm', [], false], 0, ['AD_OWNER_INVALID']), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, 'adm', [], false], 0, ['AD_OWNER_INVALID']), - array(false, ['', 'Ad Note #1', 'Ad Code #1', 0, '', 'blah', 'blah', 0, 0, -1, -1, 'adm', [], false], 0, [ + array(false, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, '', [], false, 1], 0, ['FORM_INVALID']), + array(true, ['', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, '', [], false, 1], 0, ['AD_NAME_REQUIRED']), + array(true, [str_repeat('a', 256), 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, '', [], false, 1], 0, ['AD_NAME_TOO_LONG']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code with emoji 😀', 0, '', '', '', 5, 0, 0, 0, '', [], false, 1], 0, ['AD_CODE_ILLEGAL_CHARS']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', 'blah', '', 5, 0, 0, 0, '', [], false, 1], 0, ['AD_START_DATE_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', 'blah', 5, 0, 0, 0, '', [], false, 1], 0, ['AD_END_DATE_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '1970-01-01', '', 5, 0, 0, 0, '', [], false, 1], 0, ['AD_START_DATE_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '1970-01-01', 5, 0, 0, 0, '', [], false, 1], 0, ['AD_END_DATE_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '2060-01-01', '2050-01-01', 5, 0, 0, 0, '', [], false, 1], 0, ['END_DATE_TOO_SOON']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 0, 0, 0, 0, '', [], false, 1], 0, ['AD_PRIORITY_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 11, 0, 0, 0, '', [], false, 1], 0, ['AD_PRIORITY_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, -1, 0, '', [], false, 1], 0, ['AD_VIEWS_LIMIT_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, -1, '', [], false, 1], 0, ['AD_CLICKS_LIMIT_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, 'adm', [], false, 1], 0, ['AD_OWNER_INVALID']), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', 0, '', '', '', 5, 0, 0, 0, 'adm', [], false, 1], 0, ['AD_OWNER_INVALID']), + array(false, ['', 'Ad Note #1', 'Ad Code #1', 0, '', 'blah', 'blah', 0, 0, -1, -1, 'adm', [], false, 1], 0, [ 'FORM_INVALID', 'AD_NAME_REQUIRED', 'AD_START_DATE_INVALID', @@ -132,7 +132,7 @@ public function get_form_data_data() 'AD_CLICKS_LIMIT_INVALID', 'AD_OWNER_INVALID', ]), - array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', '1', array('above_header', 'above_footer'), '2018-01-01', '2033-01-01', '4', '1', '50', '30', 'admin', ['5'], 0], 2, []), + array(true, ['Ad Name #1', 'Ad Note #1', 'Ad Code #1', '1', array('above_header', 'above_footer'), '2018-01-01', '2033-01-01', '4', '1', '50', '30', 'admin', ['5'], 0, 0], 2, []), ); } @@ -143,14 +143,14 @@ public function get_form_data_data() */ public function test_get_form_data($valid_form, $data, $ad_owner_expected, $errors) { - [$ad_name, $ad_note, $ad_code, $ad_enabled, $ad_locations, $ad_start_date, $ad_end_date, $ad_priority, $ad_content_only, $ad_views_limit, $ad_clicks_limit, $ad_owner, $ad_groups, $ad_centering] = $data; + [$ad_name, $ad_note, $ad_code, $ad_enabled, $ad_locations, $ad_start_date, $ad_end_date, $ad_priority, $ad_content_only, $ad_views_limit, $ad_clicks_limit, $ad_owner, $ad_groups, $ad_centering, $ad_consent] = $data; self::$valid_form = $valid_form; $input_controller = $this->get_input_controller(); - $this->request->expects(self::exactly(14)) + $this->request->expects(self::exactly(15)) ->method('variable') - ->will(self::onConsecutiveCalls($ad_name, $ad_note, $ad_code, $ad_enabled, $ad_locations, $ad_start_date, $ad_end_date, $ad_priority, $ad_content_only, $ad_views_limit, $ad_clicks_limit, $ad_owner, $ad_groups, $ad_centering)); + ->will(self::onConsecutiveCalls($ad_name, $ad_note, $ad_code, $ad_enabled, $ad_locations, $ad_start_date, $ad_end_date, $ad_priority, $ad_content_only, $ad_views_limit, $ad_clicks_limit, $ad_owner, $ad_groups, $ad_centering, $ad_consent)); $result = $input_controller->get_form_data(); @@ -176,6 +176,7 @@ public function test_get_form_data($valid_form, $data, $ad_owner_expected, $erro 'ad_owner' => $ad_owner_expected, 'ad_groups' => $ad_groups, 'ad_centering' => $ad_centering, + 'ad_consent' => $ad_consent, ), $result); } } diff --git a/tests/controller/helper_test.php b/tests/controller/helper_test.php index 5d8a1eb7..79f035f6 100644 --- a/tests/controller/helper_test.php +++ b/tests/controller/helper_test.php @@ -152,6 +152,7 @@ public function assign_data_data() 'ad_clicks_limit' => 0, 'ad_owner' => 0, 'ad_centering' => false, + 'ad_consent' => 1, 'ad_locations' => [], ), '', array('AD_PRIORITY_INVALID'), true, 'AD_PRIORITY_INVALID'), array(array( @@ -167,6 +168,7 @@ public function assign_data_data() 'ad_clicks_limit' => 0, 'ad_owner' => 0, 'ad_centering' => 0, + 'ad_consent' => 1, 'ad_locations' => [], ), '', array('AD_PRIORITY_INVALID', 'AD_NAME_REQUIRED'), true, 'AD_PRIORITY_INVALID
AD_NAME_REQUIRED'), array(array( @@ -182,6 +184,7 @@ public function assign_data_data() 'ad_clicks_limit' => 0, 'ad_owner' => 99, 'ad_centering' => 0, + 'ad_consent' => 1, 'ad_locations' => [], ), 'Anonymous', array(), false, ''), array(array( @@ -197,6 +200,7 @@ public function assign_data_data() 'ad_clicks_limit' => 0, 'ad_owner' => 99, 'ad_centering' => 0, + 'ad_consent' => 1, 'ad_locations' => [], ), 'Anonymous', array(), false, ''), array(array( @@ -212,6 +216,7 @@ public function assign_data_data() 'ad_clicks_limit' => 0, 'ad_owner' => 2, 'ad_centering' => 0, + 'ad_consent' => 0, 'ad_locations' => [], ), 'admin', array(), false, ''), ); @@ -252,6 +257,7 @@ public function test_assign_data($data, $owner, $errors, $s_errors, $error_msg) 'AD_CLICKS_LIMIT' => $data['ad_clicks_limit'], 'AD_OWNER' => $owner, 'AD_CENTERING' => $data['ad_centering'], + 'AD_CONSENT' => $data['ad_consent'], )); $helper->assign_data($data, $errors); diff --git a/tests/event/main_listener_test.php b/tests/event/main_listener_test.php index 803fe43a..2e9c184f 100644 --- a/tests/event/main_listener_test.php +++ b/tests/event/main_listener_test.php @@ -35,6 +35,40 @@ public function test_getSubscribedEvents() 'core.adm_page_header_after', 'core.group_add_user_after', 'core.group_delete_user_after', + 'phpbb.consentmanager.collect_registrations', ), array_keys(\phpbb\ads\event\main_listener::getSubscribedEvents())); } + + public function test_register_ads() + { + $this->language->add_lang('common', 'phpbb/ads'); + $listener = $this->get_listener(); + $consent_manager = new consent_manager_double(); + + $listener->register_ads(array( + 'consent_manager' => $consent_manager, + )); + + self::assertCount(1, $consent_manager->registrations); + self::assertSame('phpbb.ads', $consent_manager->registrations[0]['id']); + self::assertSame(array( + 'label' => $this->language->lang('PHPBB_ADS_CONSENT_LABEL'), + 'category' => \phpbb\ads\ad\manager::CONSENT_CATEGORY, + 'description' => $this->language->lang('PHPBB_ADS_CONSENT_DESCRIPTION'), + ), $consent_manager->registrations[0]['definition']); + } +} + +class consent_manager_double +{ + /** @var array */ + public $registrations = array(); + + public function register($id, array $definition) + { + $this->registrations[] = array( + 'id' => $id, + 'definition' => $definition, + ); + } } diff --git a/tests/event/setup_ads_consentmanager_test.php b/tests/event/setup_ads_consentmanager_test.php new file mode 100644 index 00000000..3d4580fa --- /dev/null +++ b/tests/event/setup_ads_consentmanager_test.php @@ -0,0 +1,250 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\ads\tests\event; + +class setup_ads_consentmanager_test extends main_listener_base +{ + public function test_setup_ads_defers_ad_markup_when_consentmanager_is_enabled() + { + $stored_ad_code = htmlspecialchars( + '
Ad
', + ENT_COMPAT + ); + + $this->user->data['user_id'] = 1; + $this->user->page['page_name'] = 'index.' . $this->php_ext; + $this->user->page['page_dir'] = ''; + + $this->manager = $this->getMockBuilder('\phpbb\ads\ad\manager') + ->disableOriginalConstructor() + ->setMethods(array('load_memberships', 'get_ads')) + ->getMock(); + $this->location_manager = $this->getMockBuilder('\phpbb\ads\location\manager') + ->disableOriginalConstructor() + ->setMethods(array('get_all_location_ids')) + ->getMock(); + + $this->location_manager->expects(self::once()) + ->method('get_all_location_ids') + ->willReturn(array('above_header')); + + $this->manager->expects(self::once()) + ->method('load_memberships') + ->with(1) + ->willReturn(array()); + + $this->manager->expects(self::once()) + ->method('get_ads') + ->with(array('above_header'), array(), false) + ->willReturn(array(array( + 'location_id' => 'above_header', + 'ad_id' => 42, + 'ad_code' => $stored_ad_code, + 'ad_centering' => 0, + ))); + + $this->template->expects(self::exactly(2)) + ->method('retrieve_var') + ->willReturnCallback(function ($var_name) + { + return $var_name === 'S_CONSENTMANAGER_MARKETING_ENABLED'; + }); + + $this->template->expects(self::once()) + ->method('assign_vars') + ->with(self::callback(function ($vars) + { + return $vars['AD_ABOVE_HEADER_ID'] === 42 + && $vars['AD_ABOVE_HEADER_CENTER'] === false + && strpos($vars['AD_ABOVE_HEADER'], 'type="text/plain"') !== false + && strpos($vars['AD_ABOVE_HEADER'], 'data-consent-category="marketing"') !== false + && strpos($vars['AD_ABOVE_HEADER'], 'src="https://ads.example.com/tag.js"') !== false + && strpos($vars['AD_ABOVE_HEADER'], '') !== false + && strpos($vars['AD_ABOVE_HEADER'], 'phpbb-ads-consent-placeholder') === false; + })); + + $this->get_listener()->setup_ads(); + } + + public function test_setup_ads_adds_consent_category_to_text_plain_scripts() + { + $stored_ad_code = htmlspecialchars( + '', + ENT_COMPAT + ); + + $this->user->data['user_id'] = 1; + $this->user->page['page_name'] = 'index.' . $this->php_ext; + $this->user->page['page_dir'] = ''; + + $this->manager = $this->getMockBuilder('\phpbb\ads\ad\manager') + ->disableOriginalConstructor() + ->setMethods(array('load_memberships', 'get_ads')) + ->getMock(); + $this->location_manager = $this->getMockBuilder('\phpbb\ads\location\manager') + ->disableOriginalConstructor() + ->setMethods(array('get_all_location_ids')) + ->getMock(); + + $this->location_manager->expects(self::once()) + ->method('get_all_location_ids') + ->willReturn(array('above_header')); + + $this->manager->expects(self::once()) + ->method('load_memberships') + ->with(1) + ->willReturn(array()); + + $this->manager->expects(self::once()) + ->method('get_ads') + ->with(array('above_header'), array(), false) + ->willReturn(array(array( + 'location_id' => 'above_header', + 'ad_id' => 99, + 'ad_code' => $stored_ad_code, + 'ad_centering' => 0, + ))); + + $this->template->expects(self::exactly(2)) + ->method('retrieve_var') + ->willReturnCallback(function ($var_name) + { + return $var_name === 'S_CONSENTMANAGER_MARKETING_ENABLED'; + }); + + $this->template->expects(self::once()) + ->method('assign_vars') + ->with(self::callback(function ($vars) + { + return $vars['AD_ABOVE_HEADER_ID'] === 99 + && strpos($vars['AD_ABOVE_HEADER'], 'type="text/plain"') !== false + && strpos($vars['AD_ABOVE_HEADER'], 'data-consent-category="marketing"') !== false + && strpos($vars['AD_ABOVE_HEADER'], 'src="https://ads.example.com/legacy.js"') !== false; + })); + + $this->get_listener()->setup_ads(); + } + + public function test_setup_ads_does_not_defer_when_marketing_category_is_disabled() + { + $stored_ad_code = htmlspecialchars( + '', + ENT_COMPAT + ); + + $this->user->data['user_id'] = 1; + $this->user->page['page_name'] = 'index.' . $this->php_ext; + $this->user->page['page_dir'] = ''; + + $this->manager = $this->getMockBuilder('\phpbb\ads\ad\manager') + ->disableOriginalConstructor() + ->setMethods(array('load_memberships', 'get_ads')) + ->getMock(); + $this->location_manager = $this->getMockBuilder('\phpbb\ads\location\manager') + ->disableOriginalConstructor() + ->setMethods(array('get_all_location_ids')) + ->getMock(); + + $this->location_manager->expects(self::once()) + ->method('get_all_location_ids') + ->willReturn(array('above_header')); + + $this->manager->expects(self::once()) + ->method('load_memberships') + ->with(1) + ->willReturn(array()); + + $this->manager->expects(self::once()) + ->method('get_ads') + ->with(array('above_header'), array(), false) + ->willReturn(array(array( + 'location_id' => 'above_header', + 'ad_id' => 77, + 'ad_code' => $stored_ad_code, + 'ad_centering' => 0, + ))); + + $this->template->expects(self::exactly(2)) + ->method('retrieve_var') + ->willReturn(false); + + $this->template->expects(self::once()) + ->method('assign_vars') + ->with(self::callback(function ($vars) + { + return strpos($vars['AD_ABOVE_HEADER'], 'type="text/plain"') === false + && strpos($vars['AD_ABOVE_HEADER'], 'data-consent-category="marketing"') === false + && strpos($vars['AD_ABOVE_HEADER'], 'src="https://ads.example.com/tag.js"') !== false; + })); + + $this->get_listener()->setup_ads(); + } + + public function test_setup_ads_does_not_defer_when_ad_consent_is_disabled() + { + $stored_ad_code = htmlspecialchars( + '', + ENT_COMPAT + ); + + $this->user->data['user_id'] = 1; + $this->user->page['page_name'] = 'index.' . $this->php_ext; + $this->user->page['page_dir'] = ''; + + $this->manager = $this->getMockBuilder('\phpbb\ads\ad\manager') + ->disableOriginalConstructor() + ->setMethods(array('load_memberships', 'get_ads')) + ->getMock(); + $this->location_manager = $this->getMockBuilder('\phpbb\ads\location\manager') + ->disableOriginalConstructor() + ->setMethods(array('get_all_location_ids')) + ->getMock(); + + $this->location_manager->expects(self::once()) + ->method('get_all_location_ids') + ->willReturn(array('above_header')); + + $this->manager->expects(self::once()) + ->method('load_memberships') + ->with(1) + ->willReturn(array()); + + $this->manager->expects(self::once()) + ->method('get_ads') + ->with(array('above_header'), array(), false) + ->willReturn(array(array( + 'location_id' => 'above_header', + 'ad_id' => 78, + 'ad_code' => $stored_ad_code, + 'ad_centering' => 0, + 'ad_consent' => 0, + ))); + + $this->template->expects(self::exactly(2)) + ->method('retrieve_var') + ->willReturnCallback(function ($var_name) + { + return $var_name === 'S_CONSENTMANAGER_MARKETING_ENABLED'; + }); + + $this->template->expects(self::once()) + ->method('assign_vars') + ->with(self::callback(function ($vars) + { + return $vars['AD_ABOVE_HEADER_ID'] === 78 + && strpos($vars['AD_ABOVE_HEADER'], 'type="text/plain"') === false + && strpos($vars['AD_ABOVE_HEADER'], 'data-consent-category="marketing"') === false + && strpos($vars['AD_ABOVE_HEADER'], 'src="https://ads.example.com/tag.js"') !== false; + })); + + $this->get_listener()->setup_ads(); + } +} diff --git a/tests/functional/functional_base.php b/tests/functional/functional_base.php index bd5395d1..f7e42a1b 100644 --- a/tests/functional/functional_base.php +++ b/tests/functional/functional_base.php @@ -42,7 +42,7 @@ protected function setUp(): void $this->admin_login(); } - protected function create_ad($location, $end_date = '', $content_only = false, $centering = true, $start_date = '') + protected function create_ad($location, $end_date = '', $content_only = false, $centering = true, $start_date = '', $ad_code = '') { // Load Advertisement management ACP page $crawler = self::request('GET', "adm/index.php?i=-phpbb-ads-acp-main_module&mode=manage&sid={$this->sid}"); @@ -51,11 +51,16 @@ protected function create_ad($location, $end_date = '', $content_only = false, $ $form = $crawler->selectButton($this->lang('ACP_ADS_ADD'))->form(); $crawler = self::submit($form); + if ($ad_code === '') + { + $ad_code = ''; + } + // Create ad $form_data = array( 'ad_name' => 'Functional test template location ' . $location, 'ad_note' => '', - 'ad_code' => '', + 'ad_code' => $ad_code, 'ad_enabled' => 1, 'ad_locations' => array($location), 'ad_start_date' => $start_date,