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: 3 additions & 1 deletion extensions/gdpr/extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export default class ErasureRequestsList<CustomAttrs extends IErasureRequestsLis
content={app.translator.trans(`flarum-gdpr.forum.erasure_requests.item_text`, {
name: username(request.user()),
})}
datetime={request.createdAt()}
datetime={request.userConfirmedAt() ?? request.createdAt()}
excerpt=""
/>
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,6 +61,27 @@ export default class ProcessErasureRequestModal<
</div>
);

const createdAt = erasureRequest.createdAt();
const userConfirmedAt = erasureRequest.userConfirmedAt();

items.add(
'timestamps',
<div className="helpText">
{createdAt && <p>{app.translator.trans('flarum-gdpr.forum.process_erasure.requested_at', { date: humanTime(createdAt) })}</p>}
{userConfirmedAt && (
<>
<p>{app.translator.trans('flarum-gdpr.forum.process_erasure.confirmed_at', { date: humanTime(userConfirmedAt) })}</p>
<p>
{app.translator.trans('flarum-gdpr.forum.process_erasure.eligible_at', {
date: humanTime(new Date(userConfirmedAt.getTime() + 30 * 24 * 60 * 60 * 1000)),
})}
</p>
</>
)}
</div>,
90
);

erasureRequest?.reason() &&
items.add(
'reason',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;

return [
'up' => 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');
});
}
},
];
3 changes: 3 additions & 0 deletions extensions/gdpr/resources/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions extensions/gdpr/src/Console/ClearConfirmationIps.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

namespace Flarum\Gdpr\Console;

use Carbon\Carbon;
use Flarum\Gdpr\Models\ErasureRequest;
use Illuminate\Console\Command;

class ClearConfirmationIps extends Command
{
const RETENTION_DAYS = 90;

protected $signature = 'gdpr:clear-confirmation-ips';
protected $description = 'Clears stored confirmation IP addresses from erasure requests older than '.self::RETENTION_DAYS.' days.';

public function handle(): void
{
ErasureRequest::query()
->whereNotNull('confirmation_ip')
->whereNotNull('user_confirmed_at')
->where('user_confirmed_at', '<=', Carbon::now()->subDays(static::RETENTION_DAYS))
->update(['confirmation_ip' => null]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions extensions/gdpr/src/Models/ErasureRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/

namespace Flarum\Gdpr\tests\integration\console;

use Carbon\Carbon;
use Flarum\Gdpr\Models\ErasureRequest;
use Flarum\Testing\integration\ConsoleTestCase;
use Flarum\User\User;
use PHPUnit\Framework\Attributes\Test;

class ClearConfirmationIpsTest extends ConsoleTestCase
{
public function setUp(): void
{
parent::setUp();

$this->extension('flarum-gdpr');

$this->prepareDatabase([
User::class => [
['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' => 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' => 4, '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);
}
}
52 changes: 50 additions & 2 deletions extensions/gdpr/tests/integration/forum/ConfirmErasureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +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' => 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)],
],
]);

Expand Down Expand Up @@ -89,10 +93,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]
Expand Down Expand Up @@ -131,10 +136,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]
Expand Down
Loading