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
95 changes: 85 additions & 10 deletions extensions/gdpr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ From here, users may self-service export their data from the forum, or start an
If your forum runs multiple queues, ie `low` and `high`, you may specify which queue jobs for this extension are run on in your skeleton's `extend.php` file:

```php
Blomstra\Gdpr\Jobs\GdprJob::$onQueue = 'low';
Flarum\Gdpr\Jobs\GdprJob::$onQueue = 'low';

return [
... your current extenders,
Expand All @@ -40,12 +40,12 @@ You can easily register a new Data type, remove an existing Data type, or exclud

#### Registering a new Data Type:

Your data type class should implement the `Blomstra\Gdpr\Contracts\DataType`:
Your data type class should implement the `Flarum\Gdpr\Contracts\DataType`:
```php
<?php

use Blomstra\Gdpr\Extend\UserData;
use Blomstra\Extend;
use Flarum\Gdpr\Extend\UserData;
use Flarum\Extend;

return [
(new Extend\Conditional())
Expand All @@ -65,8 +65,8 @@ name the file and always prefix it with your extension slug (flarum-something-fi
#### Removing a Data Type:
If for any reason you want to exclude a certain DataType from the export:
```php
use Blomstra\Gdpr\Extend\UserData;
use Blomstra\Extend;
use Flarum\Gdpr\Extend\UserData;
use Flarum\Extend;

return [
(new Extend\Conditional())
Expand All @@ -79,21 +79,96 @@ return [
];
```

#### Exclude specific columns from the user table during export:
#### Redacting specific user table columns from exports:

By default, the `User` data type exports all columns from the `users` table except `id`, `password`, `groups`, and `anonymized`. If your extension adds a column to the `users` table that should not appear in the export (e.g. a sensitive internal token), you can register it via `removeUserColumns()`.

The column's value will be **set to `null` on the in-memory user object** before the export ZIP is generated — the column still appears in `user.json` but with a `null` value. This is visible in the admin GDPR overview under "User Table Data".

```php
use Blomstra\Gdpr\Extend\UserData;
use Flarum\Gdpr\Extend\UserData;

return [
(new Extend\Conditional())
->whenExtensionEnabled('flarum-gdpr', fn () => [
(new UserData())
->removeUserColumn('column_name') // For a single column
->removeUserColumns(['column1', 'column2']), // For multiple columns
->removeUserColumns(['column1', 'column2']),

... other conditional extenders as required ...
]),
];
```

#### PII fields and anonymized contexts

##### What is an "anonymized context"?

Some extensions need to share Flarum data with external systems — for example, publishing events to a message broker, syncing to a search index, or sending webhooks. In these scenarios there are typically two audiences:

- **Full-data consumers** — internal systems that are authorised to process PII (e.g. a private analytics pipeline).
- **Anonymized consumers** — systems where PII must not appear (e.g. a public event stream, a third-party integration, or any consumer that doesn't need identifying information).

An "anonymized context" is any such output where PII keys must be redacted before the data leaves the application. For example, [glowingblue/rabbit-dispatcher](https://github.com/glowingblue/rabbit-dispatcher) publishes Flarum events to RabbitMQ on two exchanges simultaneously: one with full payloads, and one with all PII keys replaced by `[redacted]`. The PII key list comes from `flarum/gdpr` so that every registered extension's sensitive fields are automatically covered.

The GDPR admin page ("User Table Data" section) shows which fields are currently registered as PII, giving admins visibility into what will be redacted.

##### Declaring PII fields on your data type

If your extension stores personally identifiable information, declare which keys are PII by overriding `piiFields()` on your data type class. This is the preferred approach — the declaration lives alongside your `anonymize()` logic, and the keys are automatically included in the PII registry as soon as your type is registered.

```php
use Flarum\Gdpr\Data\Type;

class MyData extends Type
{
public static function piiFields(): array
{
return ['custom_field', 'another_pii_field'];
}

// ... export(), anonymize(), delete() ...
}
```

##### Declaring PII fields without a data type

If your extension stores PII in a field that doesn't belong to any registered data type (e.g. a column on a model you don't export via GDPR), register the keys via the `UserData` extender instead:

```php
use Flarum\Gdpr\Extend\UserData;

return [
(new Extend\Conditional())
->whenExtensionEnabled('flarum-gdpr', fn () => [
(new UserData())
->addPiiKeysForSerialization(['custom_field', 'another_pii_field']),
]),
];
```

##### Building an anonymized context (consuming the PII list)

If you are building an extension that serializes Flarum data for an external system and want to support PII redaction, resolve the PII key list from `DataProcessor` at runtime. Always check whether `flarum-gdpr` is enabled first and provide your own fallback for when it is not:

```php
use Flarum\Extension\ExtensionManager;
use Flarum\Gdpr\DataProcessor;

$extensions = resolve(ExtensionManager::class);

if ($extensions->isEnabled('flarum-gdpr')) {
$piiKeys = resolve(DataProcessor::class)->getPiiKeysForSerialization();
} else {
// Fallback covering common fields — used when flarum/gdpr is not installed.
$piiKeys = ['email', 'username', 'ip_address', 'last_ip_address'];
}

// Recursively redact PII from your serialized payload before sending externally.
$anonymizedPayload = redactKeys($payload, $piiKeys);
```

`getPiiKeysForSerialization()` aggregates fields declared by all registered data types (via `piiFields()`) plus any extras registered via `addPiiKeysForSerialization()`. This means every enabled extension that participates in the GDPR registry contributes its PII fields automatically — your consumer code doesn't need to know about them individually.

### Flarum extensions

These are the known extensions which offer GDPR data integration with this extension. Don't see a required extension listed? Contact the author to request it
Expand Down
82 changes: 81 additions & 1 deletion extensions/gdpr/js/src/admin/components/GdprPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@ import Tooltip from 'flarum/common/components/Tooltip';
import ExtensionLink from './ExtensionLink';
import LinkButton from 'flarum/common/components/LinkButton';
import Form from 'flarum/common/components/Form';
import Icon from 'flarum/common/components/Icon';

type ColumnMeta = { type: string; length: number | null; default: string | null; nullable: boolean };
type UserColumnsData = {
allColumns: Record<string, ColumnMeta>;
removableColumns: Record<string, string | null>;
piiKeys: string[];
piiKeyExtensions: Record<string, string | null>;
};

export default class GdprPage<CustomAttrs extends IPageAttrs = IPageAttrs> extends AdminPage<CustomAttrs> {
gdprDataTypes: DataType[] = [];
userColumnsData: UserColumnsData | null = null;

oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);

this.loadGdprDataTypes();
this.loadUserColumnsData();
}

headerInfo(): AdminHeaderAttrs {
Expand All @@ -36,6 +47,18 @@ export default class GdprPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
});
}

loadUserColumnsData() {
app
.request<{ data: UserColumnsData }>({
method: 'GET',
url: app.forum.attribute('apiUrl') + '/gdpr-datatypes/user-columns',
})
.then((response) => {
this.userColumnsData = response.data;
m.redraw();
});
}

content(): Mithril.Children {
if (this.loading) {
return <LoadingIndicator />;
Expand All @@ -61,7 +84,7 @@ export default class GdprPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
<div className="Form-group">
<label>{app.translator.trans('flarum-gdpr.admin.gdpr_page.user_table_data.title')}</label>
<p className="helpText">{app.translator.trans('flarum-gdpr.admin.gdpr_page.user_table_data.help_text')}</p>
<div className="GdprUserColumnData">{app.translator.trans('flarum-gdpr.admin.gdpr_page.user_table_data.not_yet_implemented')}</div>
{this.userColumnsData ? this.userColumnTable(this.userColumnsData) : <LoadingIndicator />}
</div>
</Form>
</div>
Expand Down Expand Up @@ -99,4 +122,61 @@ export default class GdprPage<CustomAttrs extends IPageAttrs = IPageAttrs> exten
</div>
);
}

userColumnTable({ allColumns, removableColumns, piiKeys, piiKeyExtensions }: UserColumnsData): Mithril.Children {
const t = (key: string) => app.translator.trans(`flarum-gdpr.admin.gdpr_page.user_table_data.${key}`);

return (
<div className="GdprGrid GdprGrid--userColumns">
<div className="GdprGrid-row">
<div className="GdprGrid-header">{t('column')}</div>
<div className="GdprGrid-header">{t('type')}</div>
<div className="GdprGrid-header">{t('nullable')}</div>
<div className="GdprGrid-header">{t('pii')}</div>
<div className="GdprGrid-header">{t('redacted_on_export')}</div>
<div className="GdprGrid-header">{t('extension')}</div>
</div>

{Object.entries(allColumns).map(([column, meta]) => {
const isPii = piiKeys.includes(column);
const redactedByExtension = column in removableColumns ? removableColumns[column] : undefined;
const isRedacted = redactedByExtension !== undefined;
const extensionId = redactedByExtension ?? (column in piiKeyExtensions ? piiKeyExtensions[column] : null);

return (
<div className="GdprGrid-row">
<div>
<code>{column}</code>
</div>
<div className="helpText">{meta.type}</div>
<div className="helpText">{meta.nullable ? t('yes') : t('no')}</div>
<div>
{isPii ? (
<Tooltip text={t('pii_tooltip')}>
<span className="GdprPiiBadge">
<Icon name="fas fa-user-secret" /> {t('yes')}
</span>
</Tooltip>
) : (
<span className="helpText">{t('no')}</span>
)}
</div>
<div>
{isRedacted ? (
<Tooltip text={t('redacted_on_export_tooltip')}>
<span>{t('yes')}</span>
</Tooltip>
) : (
<span className="helpText">{t('no')}</span>
)}
</div>
<div>
<ExtensionLink extension={extensionId ? (app.data.extensions[extensionId] ?? null) : null} />
</div>
</div>
);
})}
</div>
);
}
}
24 changes: 10 additions & 14 deletions extensions/gdpr/js/src/admin/extend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,15 @@ export default [
.add('gdpr-datatypes', DataType),

new Extend.Admin()
.setting(() => ({
setting: 'flarum-gdpr.gdpr_page_link',
type: 'custom',
component: () => (
<div className="Form-group">
<label>{app.translator.trans('flarum-gdpr.admin.settings.gdpr_page.title')}</label>
<p className="helpText">{app.translator.trans('flarum-gdpr.admin.settings.gdpr_page.help_text')}</p>
<LinkButton href={app.route('gdpr')} icon="fas fa-user-shield" className="Button">
{app.translator.trans('flarum-gdpr.admin.nav.gdpr_button')}
</LinkButton>
</div>
),
}))
.customSetting(() => (
<div className="Form-group">
<label>{app.translator.trans('flarum-gdpr.admin.settings.gdpr_page.title')}</label>
<p className="helpText">{app.translator.trans('flarum-gdpr.admin.settings.gdpr_page.help_text')}</p>
<LinkButton href={app.route('gdpr')} icon="fas fa-user-shield" className="Button">
{app.translator.trans('flarum-gdpr.admin.nav.gdpr_button')}
</LinkButton>
</div>
))
.setting(() => ({
setting: 'flarum-gdpr.allow-anonymization',
label: app.translator.trans('flarum-gdpr.admin.settings.allow_anonymization'),
Expand All @@ -52,7 +48,7 @@ export default [
}))
.setting(() => ({
setting: 'flarum-gdpr.default-anonymous-username',
type: 'string',
type: 'text',
label: app.translator.trans('flarum-gdpr.admin.settings.default_anonymous_username'),
help: app.translator.trans('flarum-gdpr.admin.settings.default_anonymous_username_help'),
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ export default class ExportAvailableNotification extends Notification {
return 'fas fa-file-export';
}

href() {
exportUrl() {
const exportModel = this.attrs.notification.subject() as Export;

// Building the full url scheme so that Mithril treats this as an external link, so the download will work correctly.
return app.forum.attribute<string>('baseUrl') + `/gdpr/export/${exportModel.file()}`;
}

href() {
// Return a non-navigating href; the download is opened via window.open() in
// markAsRead() so the mark-as-read XHR is not aborted by a page navigation.
return '#';
}

content() {
const notification = this.attrs.notification;
return app.translator.trans('flarum-gdpr.forum.notification.export-ready', {
Expand All @@ -25,4 +29,16 @@ export default class ExportAvailableNotification extends Notification {
excerpt() {
return null;
}

markAsRead() {
// Open the download in a new tab so the current page is not navigated away
// from, keeping the mark-as-read XHR alive.
window.open(this.exportUrl(), '_blank');

if (this.attrs.notification.isRead()) return;

app.session.user?.pushAttributes({ unreadNotificationCount: (app.session.user.unreadNotificationCount() ?? 1) - 1 });

this.attrs.notification.save({ isRead: true });
}
}
18 changes: 16 additions & 2 deletions extensions/gdpr/resources/less/admin.less
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,28 @@
background-color: var(--control-bg);
color: var(--control-color);
border-bottom: 1px solid var(--muted-color);

> div {
background-color: var(--control-bg);
}
}
}

.GdprGrid--userColumns {
.GdprGrid-row {
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr;
}
}

.GdprPiiBadge {
display: inline-flex;
align-items: center;
gap: 4px;
color: @primary-color;
font-weight: 600;
}
}


[data-theme^=light] {
--body-bg-darken-3: darken(@body-bg-light, 3%);
Expand Down
12 changes: 11 additions & 1 deletion extensions/gdpr/resources/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,20 @@ flarum-gdpr:
help_text: Looking for GDPR settings? They're found on the extension page.
extension_settings_button: GDPR Settings
user_table_data:
not_yet_implemented: Not yet implemented
title: User Table Data
help_text: |
On the most part, any columns added to the <code>user</code> table will be handled automatically, both for exporting data and for erasure.
However, there are some special cases, which are listed below.
column: Column
type: Type
nullable: Nullable
pii: PII
redacted_on_export: Redacted on export
extension: Extension
yes: Yes
no: No
pii_tooltip: This column is considered personally identifiable information and will be redacted in anonymized contexts (e.g. anonymized event payloads).
redacted_on_export_tooltip: "This column's value is blanked (set to null) when generating a user data export. The column still appears in the export with a null value."
nav:
gdpr_button: GDPR Integrations
gdpr_title: => flarum-gdpr.admin.gdpr_page.description
Expand Down Expand Up @@ -62,6 +71,7 @@ flarum-gdpr:
export_description: Exports all discussions the user has started. Data restricted to title and creation date.
forum:
export_description: Exports the forum title, url, username, email and the current date.
default_user_action: "No action, handled by default user table data handling."
no_action: No action taken.
posts:
anonymize_description: Removes the IP address from all posts the user has made.
Expand Down
Loading
Loading