Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to this project will be documented in this file.

## 1.0.0 - 2026-06-20

- First official release of Consent Manager.

## 1.0.0-b1 - 2026-06-09

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Consent Manager
[![Build Status](https://github.com/phpbb-extensions/consent-manager/actions/workflows/tests.yml/badge.svg)](https://github.com/phpbb-extensions/consent-manager/actions)
[![codecov](https://codecov.io/gh/phpbb-extensions/consent-manager/graph/badge.svg?token=IE2YWG6N9V)](https://codecov.io/gh/phpbb-extensions/consent-manager)
![Stability](https://img.shields.io/badge/stability-beta-pink?logo=phpBB&logoColor=white)
[![Latest Stable Version](https://poser.pugx.org/phpbb/consentmanager/v/stable)](https://www.phpbb.com/customise/db/extension/advanced_bbcode_box/)

<p><i>Modern cookie consent for phpBB</i></p>
<kbd><img src=".github/images/cm.png" width="566" height="208" alt="Consent Manager" style="width:566px; height:auto; max-width: 100%; display: block;"></kbd>
Expand Down
2 changes: 1 addition & 1 deletion adm/style/consentmanager_acp.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ <h3>{{ lang('WARNING') }}</h3>
<strong>{{ category.label }}</strong>
<ul>
{% endif %}
<li style="font-size: 0.7rem">{{ service.label }} » <em>{{ service.id }}</em></li>
<li style="font-size: 0.7rem">{{ service.label|e('html') }} » <em>{{ service.id }}</em></li>
{% endif %}
{% endfor %}
{% if category_has_services %}
Expand Down
2 changes: 1 addition & 1 deletion adm/style/consentmanager_acp_banner.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ <h3>{{ lang('WARNING') }}</h3>
</td>
{% for translation in language.TRANSLATIONS %}
<td>
<textarea name="translations[{{ language.ISO }}][{{ translation.KEY }}]" rows="4" cols="36" style="width:80%">{{ translation.VALUE|e('html') }}</textarea>
<textarea name="translations[{{ language.ISO }}][{{ translation.KEY }}]" rows="4" cols="36" style="width:80%">{{ translation.VALUE }}</textarea>
</td>
{% endfor %}
</tr>
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"type": "phpbb-extension",
"description": "GDPR-ready consent manager for phpBB forums, providing category consent, deferred script and iframe loading, consent logging, and extension integration APIs.",
"homepage": "https://www.phpbb.com/",
"version": "1.0.0-b1",
"version": "1.0.0",
"keywords": ["phpbb", "extension", "gdpr", "consent", "cookies"],
"license": "GPL-2.0-only",
"authors": [
Expand Down
35 changes: 24 additions & 11 deletions controller/acp_controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use phpbb\request\request;
use phpbb\request\request_interface;
use phpbb\template\template;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;

class acp_controller
{
Expand Down Expand Up @@ -296,19 +298,30 @@ protected function send_csv_download(array $filters)
ob_end_clean();
}

header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="consent_logs_' . gmdate('Y-m-d_His') . '.csv"');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
$this->create_csv_response($filters)->send();

$handle = fopen('php://output', 'wb');
fwrite($handle, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel compatibility
fputcsv($handle, ['anonymized_id', 'timestamp', 'consent_version', 'categories']);
$this->acp_manager->stream_logs_csv($handle, $filters);
fclose($handle);
garbage_collection();
exit_handler();
}

exit;
protected function create_csv_response(array $filters)
{
$filename = 'consent_logs_' . gmdate('Y-m-d_His') . '.csv';
$response = new StreamedResponse(function () use ($filters) {
$handle = fopen('php://output', 'wb');
fwrite($handle, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel compatibility
fputcsv($handle, ['anonymized_id', 'timestamp', 'consent_version', 'categories']);
$this->acp_manager->stream_logs_csv($handle, $filters);
fclose($handle);
});

$response->headers->set('Content-Type', 'text/csv; charset=UTF-8');
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename));
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');

return $response;
}

protected function get_logs_form_data()
Expand Down
70 changes: 66 additions & 4 deletions service/log_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@

class log_manager
{
/** Seconds during which an unchanged decision is considered a duplicate. */
public const DUPLICATE_WINDOW = 300;

/** Maximum accepted decisions per anonymized subject during the rate-limit window. */
public const RATE_LIMIT_MAX = 20;

/** Rate-limit window in seconds. */
public const RATE_LIMIT_WINDOW = 3600;

/** @var config */
protected $config;

Expand Down Expand Up @@ -50,19 +59,72 @@ public function __construct(config $config, driver_interface $db, user $user, $c
* @param array $categories Accepted category ids
* @param int $version Consent version
*
* @return void
* @return bool True when a row was inserted, false when suppressed
*/
public function log_consent(array $categories, $version)
{
$anonymized_id = $this->get_anonymized_subject();
$accepted_categories = json_encode(array_values($categories));
$now = time();

if ($this->should_suppress_submission($anonymized_id, (int) $version, $accepted_categories, $now))
{
return false;
}

$record = [
'anonymized_id' => $this->get_anonymized_subject(),
'anonymized_id' => $anonymized_id,
'consent_version' => (int) $version,
'accepted_categories' => json_encode(array_values($categories)),
'consent_time' => time(),
'accepted_categories' => $accepted_categories,
'consent_time' => $now,
];

$sql = 'INSERT INTO ' . $this->consent_logs_table . ' ' . $this->db->sql_build_array('INSERT', $record);
$this->db->sql_query($sql);

return true;
}

/**
* Suppress rapid duplicates and excessive submissions from one subject.
*
* @param string $anonymized_id Anonymized user or guest-session identifier
* @param int $version Consent version
* @param string $accepted_categories JSON-encoded normalized categories
* @param int $now Current Unix timestamp
*
* @return bool
*/
protected function should_suppress_submission($anonymized_id, $version, $accepted_categories, $now)
{
$sql = 'SELECT consent_version, accepted_categories, consent_time
FROM ' . $this->consent_logs_table . "
WHERE anonymized_id = '" . $this->db->sql_escape($anonymized_id) . "'
AND consent_time >= " . ((int) $now - self::RATE_LIMIT_WINDOW) . '
ORDER BY consent_log_id DESC';
$result = $this->db->sql_query_limit($sql, self::RATE_LIMIT_MAX);
$count = 0;
$latest = null;

while ($row = $this->db->sql_fetchrow($result))
{
if ($latest === null)
{
$latest = $row;
}
$count++;
}
$this->db->sql_freeresult($result);

if ($latest !== null
&& (int) $latest['consent_time'] >= (int) $now - self::DUPLICATE_WINDOW
&& (int) $latest['consent_version'] === (int) $version
&& $latest['accepted_categories'] === $accepted_categories)
{
return true;
}

return $count >= self::RATE_LIMIT_MAX;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion styles/all/template/event/overall_footer_body_after.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ <h3 class="consent-manager-category-title">{{ cat.LABEL }}</h3>
<div class="consent-manager-category-services">
<ul>
{% for service in cat.CONSENTMANAGER_SERVICES %}
<li><strong>{{ service.LABEL }}</strong>{% if service.DESCRIPTION %}{{ lang('COLON')}} {{ service.DESCRIPTION }}{% endif %}</li>
<li><strong>{{ service.LABEL|e('html') }}</strong>{% if service.DESCRIPTION %}{{ lang('COLON')}} {{ service.DESCRIPTION|e('html') }}{% endif %}</li>
{% endfor %}
</ul>
</div>
Expand Down
9 changes: 7 additions & 2 deletions styles/all/template/js/consentmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
const categoriesById = {};
const deferredSelector = 'script[type="text/plain"][data-consent-category]';
const deferredEmbedSelector = '[data-consent-media-container][data-consent-category]';
const googleConsentMode = payload.googleConsentMode || {};
const googleConsentTypes = googleConsentMode.types || {};
let requiredCategories = [];
let enabledCategories = [];
let optionalCategories = [];
Expand All @@ -31,6 +29,8 @@
return;
}

const googleConsentMode = payload.googleConsentMode || {};
const googleConsentTypes = googleConsentMode.types || {};
const mediaPlaceholderLabel = typeof lang.mediaPlaceholderLabel === 'string' ? lang.mediaPlaceholderLabel : '';

function isArray(value)
Expand Down Expand Up @@ -102,6 +102,11 @@
{
let cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax';

if (window.location.protocol === 'https:')
{
cookie += '; Secure';
}

if (typeof maxAge === 'number')
{
cookie += '; max-age=' + maxAge;
Expand Down
51 changes: 51 additions & 0 deletions tests/controller/acp_controller_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,52 @@ public function test_handle_logs_export_success_logs_and_passes_filters_to_downl
], $controller->captured_filters);
}

public function test_create_csv_response_streams_export_with_symfony_headers()
{
$filters = [
'user_id' => 42,
'consent_version' => 2,
];

$this->acp_manager->expects(self::once())
->method('stream_logs_csv')
->with(self::isType('resource'), $filters)
->willReturnCallback(static function ($handle) {
fputcsv($handle, ['anon-id', '2024-01-01T00:00:00Z', 2, 'necessary,analytics']);
});

$controller = new \phpbb\consentmanager\tests\controller\testable_acp_controller(
$this->language,
$this->acp_manager,
$this->translation_manager,
$this->create_request_mock(),
$this->template,
'',
'php'
);

$response = $controller->create_csv_response_for_test($filters);

self::assertInstanceOf('\Symfony\Component\HttpFoundation\StreamedResponse', $response);
self::assertSame('text/csv; charset=UTF-8', $response->headers->get('Content-Type'));
self::assertRegExp('/^attachment; filename="?consent_logs_\d{4}-\d{2}-\d{2}_\d{6}\.csv"?$/', $response->headers->get('Content-Disposition'));
self::assertTrue($response->headers->hasCacheControlDirective('no-cache'));
self::assertTrue($response->headers->hasCacheControlDirective('no-store'));
self::assertTrue($response->headers->hasCacheControlDirective('must-revalidate'));
self::assertSame('no-cache', $response->headers->get('Pragma'));
self::assertSame('0', $response->headers->get('Expires'));

ob_start();
$response->sendContent();
$content = ob_get_clean();

self::assertSame(
"\xEF\xBB\xBFanonymized_id,timestamp,consent_version,categories\n" .
"anon-id,2024-01-01T00:00:00Z,2,\"necessary,analytics\"\n",
$content
);
}

public function test_handle_logs_delete_confirmed_logs_and_triggers_success_notice()
{
self::$valid_form = true;
Expand Down Expand Up @@ -775,6 +821,11 @@ protected function send_csv_download(array $filters)
$this->captured_filters = $filters;
// Do not stream or exit — just record the filters for assertions
}

public function create_csv_response_for_test(array $filters)
{
return $this->create_csv_response($filters);
}
}

namespace phpbb\consentmanager\controller;
Expand Down
67 changes: 65 additions & 2 deletions tests/functional/acp_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,30 @@ public function test_acp_page_renders_consent_manager_settings()
$this->assertContainsLang('ACP_CONSENTMANAGER_VERSION', $crawler->filter('#main')->text());
}

public function test_acp_page_escapes_integration_label()
{
$label = '<script>alert(1)</script>';
$integrations = json_encode(array(array(
'id' => 'board.analytics',
'category' => 'analytics',
'label' => $label,
'src' => '/analytics.js',
)));

$this->db->sql_query('UPDATE ' . CONFIG_TEXT_TABLE . "
SET config_value = '" . $this->db->sql_escape($integrations) . "'
WHERE config_name = 'consentmanager_integrations'");
$this->purge_cache();

$this->login();
$this->admin_login();
self::request('GET', $this->get_module_url());
$content = self::get_content();

$this->assertStringNotContainsString($label, $content);
$this->assertStringContainsString('&lt;script&gt;alert(1)&lt;/script&gt;', $content);
}

public function test_acp_form_saves_settings_and_integrations()
{
$this->login();
Expand Down Expand Up @@ -91,9 +115,35 @@ public function test_acp_force_reprompt_increments_version()
$this->assertSame($before + 1, $this->get_consent_version());
}

protected function get_module_url()
public function test_banner_translation_does_not_double_escape_on_resave()
{
$banner_title = 'Cookies & Privacy';

$this->login();
$this->admin_login();

$crawler = self::request('GET', $this->get_module_url('banner'));
$form = $crawler->selectButton($this->lang('SUBMIT'))->form();
$form['translations[en][banner_title]']->setValue($banner_title);
$crawler = self::submit($form);

$this->assertStringContainsString($this->lang('ACP_CONSENTMANAGER_BANNER_UPDATED'), $crawler->text());
$this->assertSame('Cookies &amp; Privacy', $this->get_stored_translation('banner_title', 'en'));

$crawler = self::request('GET', $this->get_module_url('banner'));
$form = $crawler->selectButton($this->lang('SUBMIT'))->form();
$this->assertSame($banner_title, $form['translations[en][banner_title]']->getValue());
self::submit($form);

$this->assertSame('Cookies &amp; Privacy', $this->get_stored_translation('banner_title', 'en'));

$crawler = self::request('GET', 'index.php');
$this->assertSame($banner_title, $crawler->filter('#consent-manager-banner-title')->text());
}

protected function get_module_url($mode = 'settings')
{
return 'adm/index.php?i=%5Cphpbb%5Cconsentmanager%5Cacp%5Cconsentmanager_module&mode=settings&sid=' . $this->sid;
return 'adm/index.php?i=%5Cphpbb%5Cconsentmanager%5Cacp%5Cconsentmanager_module&mode=' . $mode . '&sid=' . $this->sid;
}

protected function get_consent_version()
Expand All @@ -107,4 +157,17 @@ protected function get_consent_version()

return $value;
}

protected function get_stored_translation($translation_key, $lang_iso)
{
$sql = 'SELECT translation_text
FROM phpbb_consentmanager_translations
WHERE translation_key = \'' . $this->db->sql_escape($translation_key) . '\'
AND lang_iso = \'' . $this->db->sql_escape($lang_iso) . "'";
$result = $this->db->sql_query($sql);
$value = $this->db->sql_fetchfield('translation_text');
$this->db->sql_freeresult($result);

return $value;
}
}
Loading