From 10dddca292e9097a4f3906d7df0e1c443e6112a3 Mon Sep 17 00:00:00 2001 From: IanM Date: Sun, 8 Mar 2026 21:38:51 +0100 Subject: [PATCH 1/4] feat(gdpr): port confirmation token invalidation, IP logging and IP purge (#4419) Ports flarum/gdpr PR #70 (1.x) to 2.x: - Token invalidation: verification_token set to null on confirmation, making email links true one-time links - Processed-request guard: re-visiting a confirmation link for a processed/manual request returns 422 - Confirmation IP logging: client IP stored in new confirmation_ip column on gdpr_erasure - 90-day IP purge: new gdpr:clear-confirmation-ips console command (scheduled daily) nulls confirmation_ip on records where user_confirmed_at is older than 90 days - Modal timestamps: ProcessErasureRequestModal now shows requested-at, confirmed-at, and eligible-for-auto-processing dates Co-Authored-By: Claude Sonnet 4.6 --- extensions/gdpr/extend.php | 4 +- .../forum/components/ErasureRequestsList.tsx | 2 +- .../components/ProcessErasureRequestModal.tsx | 19 +++++ ..._confirmation_ip_to_gdpr_erasure_table.php | 28 ++++++++ extensions/gdpr/resources/locale/en.yml | 3 + .../gdpr/src/Console/ClearConfirmationIps.php | 31 +++++++++ .../Controller/ConfirmErasureController.php | 8 +++ extensions/gdpr/src/Models/ErasureRequest.php | 1 + .../console/ClearConfirmationIpsTest.php | 69 +++++++++++++++++++ .../integration/forum/ConfirmErasureTest.php | 50 +++++++++++++- 10 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 extensions/gdpr/migrations/2026_03_06_000000_add_confirmation_ip_to_gdpr_erasure_table.php create mode 100644 extensions/gdpr/src/Console/ClearConfirmationIps.php create mode 100644 extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php diff --git a/extensions/gdpr/extend.php b/extensions/gdpr/extend.php index fe572b648f..8f0e9d13fb 100644 --- a/extensions/gdpr/extend.php +++ b/extensions/gdpr/extend.php @@ -70,8 +70,10 @@ (new Extend\Console()) ->command(Console\DestroyExportsCommand::class) ->command(Console\ProcessEraseRequests::class) + ->command(Console\ClearConfirmationIps::class) ->schedule(Console\ProcessEraseRequests::class, Console\DailySchedule::class) - ->schedule(Console\DestroyExportsCommand::class, Console\DailySchedule::class), + ->schedule(Console\DestroyExportsCommand::class, Console\DailySchedule::class) + ->schedule(Console\ClearConfirmationIps::class, Console\DailySchedule::class), (new Extend\ServiceProvider()) ->register(Providers\GdprProvider::class), diff --git a/extensions/gdpr/js/src/forum/components/ErasureRequestsList.tsx b/extensions/gdpr/js/src/forum/components/ErasureRequestsList.tsx index 7bb6c73ac2..bd5379ed02 100644 --- a/extensions/gdpr/js/src/forum/components/ErasureRequestsList.tsx +++ b/extensions/gdpr/js/src/forum/components/ErasureRequestsList.tsx @@ -59,7 +59,7 @@ export default class ErasureRequestsList diff --git a/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx b/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx index 7bdcb9c73a..4a70c970d8 100644 --- a/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx +++ b/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx @@ -2,6 +2,7 @@ import Form from 'flarum/common/components/Form'; import app from 'flarum/forum/app'; import type { IInternalModalAttrs } from 'flarum/common/components/Modal'; import Button from 'flarum/common/components/Button'; +import humanTime from 'flarum/common/helpers/humanTime'; import username from 'flarum/common/helpers/username'; import extractText from 'flarum/common/utils/extractText'; import ItemList from 'flarum/common/utils/ItemList'; @@ -60,6 +61,24 @@ export default class ProcessErasureRequestModal< ); + items.add( + 'timestamps', +
+

{app.translator.trans('flarum-gdpr.forum.process_erasure.requested_at', { date: humanTime(erasureRequest.createdAt()) })}

+ {erasureRequest.userConfirmedAt() && ( + <> +

{app.translator.trans('flarum-gdpr.forum.process_erasure.confirmed_at', { date: humanTime(erasureRequest.userConfirmedAt()!) })}

+

+ {app.translator.trans('flarum-gdpr.forum.process_erasure.eligible_at', { + date: humanTime(new Date(erasureRequest.userConfirmedAt()!.getTime() + 30 * 24 * 60 * 60 * 1000)), + })} +

+ + )} +
, + 90 + ); + erasureRequest?.reason() && items.add( 'reason', diff --git a/extensions/gdpr/migrations/2026_03_06_000000_add_confirmation_ip_to_gdpr_erasure_table.php b/extensions/gdpr/migrations/2026_03_06_000000_add_confirmation_ip_to_gdpr_erasure_table.php new file mode 100644 index 0000000000..14f7d1b3f2 --- /dev/null +++ b/extensions/gdpr/migrations/2026_03_06_000000_add_confirmation_ip_to_gdpr_erasure_table.php @@ -0,0 +1,28 @@ + function (Builder $schema) { + if (! $schema->hasColumn('gdpr_erasure', 'confirmation_ip')) { + $schema->table('gdpr_erasure', function (Blueprint $table) { + $table->string('confirmation_ip', 45)->nullable(); + }); + } + }, + 'down' => function (Builder $schema) { + if ($schema->hasColumn('gdpr_erasure', 'confirmation_ip')) { + $schema->table('gdpr_erasure', function (Blueprint $table) { + $table->dropColumn('confirmation_ip'); + }); + } + }, +]; diff --git a/extensions/gdpr/resources/locale/en.yml b/extensions/gdpr/resources/locale/en.yml index 94b9428ab1..fe383445d5 100644 --- a/extensions/gdpr/resources/locale/en.yml +++ b/extensions/gdpr/resources/locale/en.yml @@ -160,6 +160,9 @@ flarum-gdpr: comments_label: Comments (optional) anonymization_button: Anonymize user deletion_button: Delete user + requested_at: "Requested: {date}" + confirmed_at: "Confirmed: {date}" + eligible_at: "Eligible for auto-processing: {date}" request_erasure: title: Request account erasure diff --git a/extensions/gdpr/src/Console/ClearConfirmationIps.php b/extensions/gdpr/src/Console/ClearConfirmationIps.php new file mode 100644 index 0000000000..4cfd86923d --- /dev/null +++ b/extensions/gdpr/src/Console/ClearConfirmationIps.php @@ -0,0 +1,31 @@ +whereNotNull('confirmation_ip') + ->whereNotNull('user_confirmed_at') + ->where('user_confirmed_at', '<=', Carbon::now()->subDays(static::RETENTION_DAYS)) + ->update(['confirmation_ip' => null]); + } +} diff --git a/extensions/gdpr/src/Http/Controller/ConfirmErasureController.php b/extensions/gdpr/src/Http/Controller/ConfirmErasureController.php index 214d988c1f..0a7bf1162c 100644 --- a/extensions/gdpr/src/Http/Controller/ConfirmErasureController.php +++ b/extensions/gdpr/src/Http/Controller/ConfirmErasureController.php @@ -45,9 +45,17 @@ public function handle(Request $request): ResponseInterface throw new ValidationException(['user' => 'Erase requests cannot be confirmed by different users.']); } + if (in_array($erasureRequest->status, [ErasureRequest::STATUS_PROCESSED, ErasureRequest::STATUS_MANUAL])) { + throw new ValidationException(['request' => 'This erasure request has already been processed.']); + } + + $ip = $request->getAttribute('ipAddress'); + $erasureRequest->user_confirmed_at = Carbon::now(); $erasureRequest->status = ErasureRequest::STATUS_USER_CONFIRMED; $erasureRequest->cancelled_at = null; + $erasureRequest->verification_token = null; + $erasureRequest->confirmation_ip = $ip; $erasureRequest->save(); return new RedirectResponse($this->url->to('forum')->base().'?erasureRequestConfirmed=1'); diff --git a/extensions/gdpr/src/Models/ErasureRequest.php b/extensions/gdpr/src/Models/ErasureRequest.php index 84b1b81e61..c0498d087d 100644 --- a/extensions/gdpr/src/Models/ErasureRequest.php +++ b/extensions/gdpr/src/Models/ErasureRequest.php @@ -30,6 +30,7 @@ * @property Carbon|null $processed_at * @property string|null $processed_mode * @property Carbon|null $cancelled_at + * @property string|null $confirmation_ip */ class ErasureRequest extends AbstractModel { diff --git a/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php b/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php new file mode 100644 index 0000000000..e448c254ba --- /dev/null +++ b/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php @@ -0,0 +1,69 @@ +extension('flarum-gdpr'); + + $this->prepareDatabase([ + User::class => [ + ['id' => 2, 'username' => 'normal', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1], + ], + 'gdpr_erasure' => [ + // Old confirmed request with IP — should be cleared (91 days ago) + ['id' => 1, 'user_id' => 2, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(91), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => '1.2.3.4'], + // Recent confirmed request with IP — should NOT be cleared (1 day ago) + ['id' => 2, 'user_id' => 2, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(1), 'user_confirmed_at' => Carbon::now()->subDays(1), 'confirmation_ip' => '5.6.7.8'], + // Old confirmed request without IP — unaffected + ['id' => 3, 'user_id' => 2, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(91), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => null], + ], + ]); + } + + #[Test] + public function ips_older_than_90_days_are_cleared() + { + $this->runCommand(['command' => 'gdpr:clear-confirmation-ips']); + + $erasureRequest = ErasureRequest::query()->find(1); + $this->assertNull($erasureRequest->confirmation_ip); + } + + #[Test] + public function recent_ips_are_not_cleared() + { + $this->runCommand(['command' => 'gdpr:clear-confirmation-ips']); + + $erasureRequest = ErasureRequest::query()->find(2); + $this->assertEquals('5.6.7.8', $erasureRequest->confirmation_ip); + } + + #[Test] + public function records_without_ip_are_unaffected() + { + $this->runCommand(['command' => 'gdpr:clear-confirmation-ips']); + + $erasureRequest = ErasureRequest::query()->find(3); + $this->assertNull($erasureRequest->confirmation_ip); + // Status should be unchanged + $this->assertEquals('user_confirmed', $erasureRequest->status); + } +} diff --git a/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php b/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php index 6c259d31a3..86413a2f90 100644 --- a/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php +++ b/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php @@ -41,6 +41,8 @@ public function setUp(): void ], 'gdpr_erasure' => [ ['id' => 1, 'user_id' => 2, 'verification_token' => 'abc123', 'status' => 'awaiting_user_confirmation', 'reason' => 'I want to be forgotten', 'created_at' => Carbon::now()], + ['id' => 2, 'user_id' => 2, 'verification_token' => 'processed-token', 'status' => 'processed', 'reason' => null, 'created_at' => Carbon::now()->subDays(35), 'user_confirmed_at' => Carbon::now()->subDays(35)], + ['id' => 3, 'user_id' => 2, 'verification_token' => 'manual-token', 'status' => 'manual', 'reason' => null, 'created_at' => Carbon::now()->subDays(35), 'user_confirmed_at' => Carbon::now()->subDays(35)], ], ]); @@ -89,10 +91,11 @@ public function guest_can_confirm_erasure_with_correct_token() $this->assertEquals(302, $response->getStatusCode()); $this->assertEquals('http://localhost?erasureRequestConfirmed=1', $response->getHeaderLine('Location')); - $erasureRequest = ErasureRequest::query()->where('verification_token', 'abc123')->first(); + $erasureRequest = ErasureRequest::query()->find(1); $this->assertNotNull($erasureRequest); $this->assertEquals('user_confirmed', $erasureRequest->status); $this->assertNotNull($erasureRequest->user_confirmed_at); + $this->assertNull($erasureRequest->verification_token); } #[Test] @@ -131,10 +134,53 @@ public function user_can_confirm_erasure_with_correct_token() $this->assertEquals(302, $response->getStatusCode()); $this->assertEquals('http://localhost?erasureRequestConfirmed=1', $response->getHeaderLine('Location')); - $erasureRequest = ErasureRequest::query()->where('verification_token', 'abc123')->first(); + $erasureRequest = ErasureRequest::query()->find(1); $this->assertNotNull($erasureRequest); $this->assertEquals('user_confirmed', $erasureRequest->status); $this->assertNotNull($erasureRequest->user_confirmed_at); + $this->assertNull($erasureRequest->verification_token); + } + + #[Test] + public function confirmation_ip_is_stored() + { + $response = $this->send( + $this->request( + 'GET', + '/gdpr/erasure/confirm/abc123' + )->withAttribute('bypassCsrfToken', true) + ); + + $this->assertEquals(302, $response->getStatusCode()); + + $erasureRequest = ErasureRequest::query()->find(1); + $this->assertNotNull($erasureRequest->confirmation_ip); + } + + #[Test] + public function processed_request_returns_422() + { + $response = $this->send( + $this->request( + 'GET', + '/gdpr/erasure/confirm/processed-token' + )->withAttribute('bypassCsrfToken', true) + ); + + $this->assertEquals(422, $response->getStatusCode()); + } + + #[Test] + public function manual_request_returns_422() + { + $response = $this->send( + $this->request( + 'GET', + '/gdpr/erasure/confirm/manual-token' + )->withAttribute('bypassCsrfToken', true) + ); + + $this->assertEquals(422, $response->getStatusCode()); } #[Test] From e21f5ce220f9d3a90bafed250ca2f67439958bd9 Mon Sep 17 00:00:00 2001 From: IanM Date: Sun, 8 Mar 2026 21:50:21 +0100 Subject: [PATCH 2/4] fix(gdpr): use local variable narrowing to satisfy humanTime Date type Extract createdAt/userConfirmedAt to local consts so TypeScript's truthiness narrowing resolves Date | null | undefined to Date, avoiding any non-null assertions. Co-Authored-By: Claude Sonnet 4.6 --- .../forum/components/ProcessErasureRequestModal.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx b/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx index 4a70c970d8..00d6f96dea 100644 --- a/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx +++ b/extensions/gdpr/js/src/forum/components/ProcessErasureRequestModal.tsx @@ -61,16 +61,19 @@ export default class ProcessErasureRequestModal< ); + const createdAt = erasureRequest.createdAt(); + const userConfirmedAt = erasureRequest.userConfirmedAt(); + items.add( 'timestamps',
-

{app.translator.trans('flarum-gdpr.forum.process_erasure.requested_at', { date: humanTime(erasureRequest.createdAt()) })}

- {erasureRequest.userConfirmedAt() && ( + {createdAt &&

{app.translator.trans('flarum-gdpr.forum.process_erasure.requested_at', { date: humanTime(createdAt) })}

} + {userConfirmedAt && ( <> -

{app.translator.trans('flarum-gdpr.forum.process_erasure.confirmed_at', { date: humanTime(erasureRequest.userConfirmedAt()!) })}

+

{app.translator.trans('flarum-gdpr.forum.process_erasure.confirmed_at', { date: humanTime(userConfirmedAt) })}

{app.translator.trans('flarum-gdpr.forum.process_erasure.eligible_at', { - date: humanTime(new Date(erasureRequest.userConfirmedAt()!.getTime() + 30 * 24 * 60 * 60 * 1000)), + date: humanTime(new Date(userConfirmedAt.getTime() + 30 * 24 * 60 * 60 * 1000)), })}

From cd44c65e9bc616799229685cf74d2cc855dd7c88 Mon Sep 17 00:00:00 2001 From: IanM Date: Sun, 8 Mar 2026 22:02:37 +0100 Subject: [PATCH 3/4] chore: fix test --- .../gdpr/tests/integration/forum/ConfirmErasureTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php b/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php index 86413a2f90..5fc3e9218b 100644 --- a/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php +++ b/extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php @@ -38,11 +38,13 @@ public function setUp(): void User::class => [ $this->normalUser(), ['id' => 3, 'username' => 'moderator', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'moderator@machine.local', 'is_email_confirmed' => 1], + ['id' => 4, 'username' => 'processed_user', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'processed@machine.local', 'is_email_confirmed' => 1], + ['id' => 5, 'username' => 'manual_user', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'manual@machine.local', 'is_email_confirmed' => 1], ], 'gdpr_erasure' => [ ['id' => 1, 'user_id' => 2, 'verification_token' => 'abc123', 'status' => 'awaiting_user_confirmation', 'reason' => 'I want to be forgotten', 'created_at' => Carbon::now()], - ['id' => 2, 'user_id' => 2, 'verification_token' => 'processed-token', 'status' => 'processed', 'reason' => null, 'created_at' => Carbon::now()->subDays(35), 'user_confirmed_at' => Carbon::now()->subDays(35)], - ['id' => 3, 'user_id' => 2, 'verification_token' => 'manual-token', 'status' => 'manual', 'reason' => null, 'created_at' => Carbon::now()->subDays(35), 'user_confirmed_at' => Carbon::now()->subDays(35)], + ['id' => 2, 'user_id' => 4, 'verification_token' => 'processed-token', 'status' => 'processed', 'reason' => null, 'created_at' => Carbon::now()->subDays(35), 'user_confirmed_at' => Carbon::now()->subDays(35)], + ['id' => 3, 'user_id' => 5, 'verification_token' => 'manual-token', 'status' => 'manual', 'reason' => null, 'created_at' => Carbon::now()->subDays(35), 'user_confirmed_at' => Carbon::now()->subDays(35)], ], ]); From e587b134153fb1a4f6b02ee73a7063b62d6fafde Mon Sep 17 00:00:00 2001 From: IanM Date: Sun, 8 Mar 2026 22:23:32 +0100 Subject: [PATCH 4/4] fix(gdpr): use unique user_ids per erasure request in test fixtures gdpr_erasure has a unique constraint on user_id. Each test fixture row needs a distinct user. Co-Authored-By: Claude Sonnet 4.6 --- .../integration/console/ClearConfirmationIpsTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php b/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php index e448c254ba..c58c4b4c9e 100644 --- a/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php +++ b/extensions/gdpr/tests/integration/console/ClearConfirmationIpsTest.php @@ -25,15 +25,17 @@ public function setUp(): void $this->prepareDatabase([ User::class => [ - ['id' => 2, 'username' => 'normal', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'normal@machine.local', 'is_email_confirmed' => 1], + ['id' => 2, 'username' => 'user_old_ip', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'user_old_ip@machine.local', 'is_email_confirmed' => 1], + ['id' => 3, 'username' => 'user_recent_ip', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'user_recent_ip@machine.local', 'is_email_confirmed' => 1], + ['id' => 4, 'username' => 'user_no_ip', 'password' => '$2y$10$LO59tiT7uggl6Oe23o/O6.utnF6ipngYjvMvaxo1TciKqBttDNKim', 'email' => 'user_no_ip@machine.local', 'is_email_confirmed' => 1], ], 'gdpr_erasure' => [ // Old confirmed request with IP — should be cleared (91 days ago) ['id' => 1, 'user_id' => 2, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(91), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => '1.2.3.4'], // Recent confirmed request with IP — should NOT be cleared (1 day ago) - ['id' => 2, 'user_id' => 2, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(1), 'user_confirmed_at' => Carbon::now()->subDays(1), 'confirmation_ip' => '5.6.7.8'], + ['id' => 2, 'user_id' => 3, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(1), 'user_confirmed_at' => Carbon::now()->subDays(1), 'confirmation_ip' => '5.6.7.8'], // Old confirmed request without IP — unaffected - ['id' => 3, 'user_id' => 2, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(91), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => null], + ['id' => 3, 'user_id' => 4, 'verification_token' => null, 'status' => 'user_confirmed', 'created_at' => Carbon::now()->subDays(91), 'user_confirmed_at' => Carbon::now()->subDays(91), 'confirmation_ip' => null], ], ]); }