mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Add GDPR compliance
This commit is contained in:
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Models\GdprRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\AdminDashboardSnapshotService;
|
||||
use App\Services\Gdpr\GdprErasureService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class DeletedUsersController extends BasePageController
|
||||
{
|
||||
@@ -33,6 +37,18 @@ class DeletedUsersController extends BasePageController
|
||||
$deletedUsers = User::onlyTrashed()
|
||||
->leftJoin('roles', 'roles.id', '=', 'users.roles_id')
|
||||
->select('users.*', 'roles.name as rolename')
|
||||
->when(Schema::hasTable('gdpr_requests'), function ($query): void {
|
||||
$query->selectSub(
|
||||
GdprRequest::query()
|
||||
->select('gdpr_requests.id')
|
||||
->whereColumn('gdpr_requests.user_id', 'users.id')
|
||||
->where('gdpr_requests.type', GdprRequest::TYPE_ERASURE)
|
||||
->where('gdpr_requests.status', GdprRequest::STATUS_COMPLETED)
|
||||
->orderByDesc('gdpr_requests.completed_at')
|
||||
->limit(1),
|
||||
'gdpr_erasure_request_id'
|
||||
);
|
||||
})
|
||||
// Qualify all columns to avoid ambiguity with joined tables
|
||||
->when($username !== '', fn ($q) => $q->where('users.username', 'like', "%$username%"))
|
||||
->when($email !== '', fn ($q) => $q->where('users.email', 'like', "%$email%"))
|
||||
@@ -107,7 +123,7 @@ class DeletedUsersController extends BasePageController
|
||||
/**
|
||||
* Bulk restore or permanent delete.
|
||||
*/
|
||||
public function bulkAction(Request $request): mixed
|
||||
public function bulkAction(Request $request, GdprErasureService $erasureService): mixed
|
||||
{
|
||||
$action = $request->input('action');
|
||||
$userIds = $request->input('user_ids', []);
|
||||
@@ -133,10 +149,17 @@ class DeletedUsersController extends BasePageController
|
||||
return redirect()->route('admin.deleted.users.index')->with('success', $count.' user(s) restored successfully.');
|
||||
}
|
||||
|
||||
$count = User::onlyTrashed()->whereIn('id', $userIds)->forceDelete();
|
||||
$count = 0;
|
||||
$actor = Auth::user();
|
||||
$actor = $actor instanceof User ? $actor : null;
|
||||
|
||||
User::onlyTrashed()->whereIn('id', $userIds)->get()->each(function (User $user) use ($erasureService, $actor, &$count): void {
|
||||
$erasureService->forceDeleteWithErasure($user, $actor);
|
||||
$count++;
|
||||
});
|
||||
Cache::forget(AdminDashboardSnapshotService::CACHE_KEY);
|
||||
|
||||
return redirect()->route('admin.deleted.users.index')->with('success', $count.' user(s) permanently deleted.');
|
||||
return redirect()->route('admin.deleted.users.index')->with('success', $count.' user(s) permanently deleted with GDPR cleanup.');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,15 +181,15 @@ class DeletedUsersController extends BasePageController
|
||||
/**
|
||||
* Permanently delete single user.
|
||||
*/
|
||||
public function permanentDelete(mixed $id): mixed
|
||||
public function permanentDelete(mixed $id, GdprErasureService $erasureService): mixed
|
||||
{
|
||||
$user = User::onlyTrashed()->find($id);
|
||||
if ($user) {
|
||||
$username = $user->username;
|
||||
$user->forceDelete();
|
||||
Cache::forget(AdminDashboardSnapshotService::CACHE_KEY);
|
||||
$actor = Auth::user();
|
||||
$erasureService->forceDeleteWithErasure($user, $actor instanceof User ? $actor : null);
|
||||
|
||||
return redirect()->route('admin.deleted.users.index')->with('success', "User '{$username}' has been permanently deleted.");
|
||||
return redirect()->route('admin.deleted.users.index')->with('success', "User '{$username}' has been permanently deleted with GDPR cleanup.");
|
||||
}
|
||||
|
||||
return redirect()->route('admin.deleted.users.index')->with('error', 'User not found.');
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Models\GdprRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Gdpr\GdprAuditService;
|
||||
use App\Services\Gdpr\GdprErasureService;
|
||||
use App\Services\Gdpr\GdprExportService;
|
||||
use App\Services\Gdpr\GdprNotificationService;
|
||||
use App\Services\Gdpr\GdprRetentionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GdprRequestController extends BasePageController
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const TYPES = [
|
||||
GdprRequest::TYPE_EXPORT,
|
||||
GdprRequest::TYPE_ERASURE,
|
||||
GdprRequest::TYPE_RECTIFICATION,
|
||||
GdprRequest::TYPE_RESTRICTION,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const STATUSES = [
|
||||
GdprRequest::STATUS_PENDING,
|
||||
GdprRequest::STATUS_PROCESSING,
|
||||
GdprRequest::STATUS_COMPLETED,
|
||||
GdprRequest::STATUS_REJECTED,
|
||||
GdprRequest::STATUS_CANCELLED,
|
||||
];
|
||||
|
||||
public function index(Request $request, GdprRetentionService $retentionService): View
|
||||
{
|
||||
$this->setAdminPrefs();
|
||||
|
||||
$type = $request->string('type')->trim()->value();
|
||||
$status = $request->string('status')->trim()->value();
|
||||
|
||||
if (! in_array($type, self::TYPES, true)) {
|
||||
$type = '';
|
||||
}
|
||||
|
||||
if (! in_array($status, self::STATUSES, true)) {
|
||||
$status = '';
|
||||
}
|
||||
|
||||
$requests = GdprRequest::query()
|
||||
->when($type !== '', fn ($query) => $query->where('type', $type))
|
||||
->when($status !== '', fn ($query) => $query->where('status', $status))
|
||||
->orderByRaw("CASE status WHEN 'pending' THEN 0 WHEN 'processing' THEN 1 WHEN 'completed' THEN 2 WHEN 'rejected' THEN 3 ELSE 4 END")
|
||||
->orderByDesc('created_at')
|
||||
->paginate((int) config('nntmux.items_per_page', 25))
|
||||
->withQueryString();
|
||||
|
||||
$this->viewData = array_merge($this->viewData, [
|
||||
'requests' => $requests,
|
||||
'types' => self::TYPES,
|
||||
'statuses' => self::STATUSES,
|
||||
'type' => $type,
|
||||
'status' => $status,
|
||||
'retentionPolicy' => $retentionService->policy(),
|
||||
'title' => 'GDPR Requests',
|
||||
'meta_title' => 'GDPR Requests',
|
||||
'meta_keywords' => 'gdpr,privacy,requests,admin',
|
||||
'meta_description' => 'Manage GDPR data subject requests',
|
||||
]);
|
||||
|
||||
return view('admin.gdpr.index', $this->viewData);
|
||||
}
|
||||
|
||||
public function show(GdprRequest $gdprRequest): View
|
||||
{
|
||||
$this->setAdminPrefs();
|
||||
|
||||
$gdprRequest->load(['auditLogs', 'processor']);
|
||||
$subject = $this->subjectFor($gdprRequest);
|
||||
|
||||
$this->viewData = array_merge($this->viewData, [
|
||||
'gdprRequest' => $gdprRequest,
|
||||
'subject' => $subject,
|
||||
'title' => 'GDPR Request #'.$gdprRequest->id,
|
||||
'meta_title' => 'GDPR Request #'.$gdprRequest->id,
|
||||
'meta_keywords' => 'gdpr,privacy,request,admin',
|
||||
'meta_description' => 'Review GDPR data subject request',
|
||||
]);
|
||||
|
||||
return view('admin.gdpr.show', $this->viewData);
|
||||
}
|
||||
|
||||
public function generateExport(GdprRequest $gdprRequest, GdprExportService $exportService): RedirectResponse
|
||||
{
|
||||
if ($gdprRequest->type !== GdprRequest::TYPE_EXPORT || ! $this->isOpen($gdprRequest)) {
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('error', 'This request cannot be exported.');
|
||||
}
|
||||
|
||||
$subject = $this->subjectFor($gdprRequest);
|
||||
if (! $subject instanceof User) {
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('error', 'The subject user could not be found.');
|
||||
}
|
||||
|
||||
$actor = $this->actor();
|
||||
|
||||
$gdprRequest->update([
|
||||
'status' => GdprRequest::STATUS_PROCESSING,
|
||||
'processed_by' => $actor?->id,
|
||||
]);
|
||||
|
||||
$exportService->generate($subject, $gdprRequest, $actor);
|
||||
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('success', 'GDPR export generated successfully.');
|
||||
}
|
||||
|
||||
public function completeErasure(GdprRequest $gdprRequest, GdprErasureService $erasureService): RedirectResponse
|
||||
{
|
||||
if ($gdprRequest->type !== GdprRequest::TYPE_ERASURE || ! $this->isOpen($gdprRequest)) {
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('error', 'This erasure request cannot be completed.');
|
||||
}
|
||||
|
||||
$subject = $this->subjectFor($gdprRequest);
|
||||
if (! $subject instanceof User) {
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('error', 'The subject user could not be found.');
|
||||
}
|
||||
|
||||
if ($subject->hasRole('Admin')) {
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('error', 'Admin accounts cannot be erased from the GDPR request queue.');
|
||||
}
|
||||
|
||||
$actor = $this->actor();
|
||||
|
||||
$gdprRequest->update([
|
||||
'status' => GdprRequest::STATUS_PROCESSING,
|
||||
'processed_by' => $actor?->id,
|
||||
]);
|
||||
|
||||
$erasureService->eraseForAccountDeletion($subject, $actor, $gdprRequest);
|
||||
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('success', 'Account erasure completed. Retained payment and audit records were anonymized or minimized where practical.');
|
||||
}
|
||||
|
||||
public function reject(Request $request, GdprRequest $gdprRequest, GdprAuditService $auditService, GdprNotificationService $notificationService): RedirectResponse
|
||||
{
|
||||
if (! $this->isOpen($gdprRequest)) {
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('error', 'This request is already closed.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'admin_notes' => ['required', 'string', 'max:4000'],
|
||||
]);
|
||||
|
||||
$actor = $this->actor();
|
||||
$subject = $this->subjectFor($gdprRequest);
|
||||
|
||||
$gdprRequest->update([
|
||||
'status' => GdprRequest::STATUS_REJECTED,
|
||||
'admin_notes' => $validated['admin_notes'],
|
||||
'processed_by' => $actor?->id,
|
||||
'completed_at' => now(),
|
||||
'response_payload' => [
|
||||
'rejected' => true,
|
||||
'reason' => $validated['admin_notes'],
|
||||
],
|
||||
]);
|
||||
|
||||
$auditService->record(
|
||||
event: 'request_rejected',
|
||||
description: 'GDPR request rejected by an administrator.',
|
||||
subject: $subject,
|
||||
actor: $actor,
|
||||
request: $gdprRequest,
|
||||
metadata: ['type' => $gdprRequest->type]
|
||||
);
|
||||
|
||||
$notificationService->requestRejected($gdprRequest->fresh() ?? $gdprRequest);
|
||||
|
||||
return redirect()->route('admin.gdpr-requests.show', $gdprRequest)->with('success', 'GDPR request rejected.');
|
||||
}
|
||||
|
||||
private function subjectFor(GdprRequest $gdprRequest): ?User
|
||||
{
|
||||
if ($gdprRequest->user_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return User::withTrashed()->find($gdprRequest->user_id);
|
||||
}
|
||||
|
||||
private function actor(): ?User
|
||||
{
|
||||
$actor = Auth::user();
|
||||
|
||||
return $actor instanceof User ? $actor : null;
|
||||
}
|
||||
|
||||
private function isOpen(GdprRequest $gdprRequest): bool
|
||||
{
|
||||
return in_array($gdprRequest->status, [GdprRequest::STATUS_PENDING, GdprRequest::STATUS_PROCESSING], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\GdprRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Gdpr\GdprAuditService;
|
||||
use App\Services\Gdpr\GdprExportService;
|
||||
use App\Services\Gdpr\GdprNotificationService;
|
||||
use App\Services\Gdpr\GdprRetentionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PrivacyCenterController extends BasePageController
|
||||
{
|
||||
public function index(GdprRetentionService $retentionService): View
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$requests = GdprRequest::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderByDesc('created_at')
|
||||
->paginate(10);
|
||||
|
||||
$this->viewData = array_merge($this->viewData, [
|
||||
'user' => $user,
|
||||
'gdprRequests' => $requests,
|
||||
'retentionPolicy' => $retentionService->policy(),
|
||||
'meta_title' => 'Privacy Center',
|
||||
'meta_keywords' => 'privacy,gdpr,data export,erasure',
|
||||
'meta_description' => 'Manage your privacy and GDPR data requests',
|
||||
]);
|
||||
|
||||
return view('privacy-center.index', $this->viewData);
|
||||
}
|
||||
|
||||
public function requestExport(Request $request, GdprExportService $exportService, GdprAuditService $auditService): RedirectResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$gdprRequest = GdprRequest::create([
|
||||
'user_id' => $user->id,
|
||||
'requester_username' => $user->username,
|
||||
'requester_email' => $user->email,
|
||||
'type' => GdprRequest::TYPE_EXPORT,
|
||||
'status' => GdprRequest::STATUS_PROCESSING,
|
||||
'request_payload' => [
|
||||
'requested_by' => 'self_service',
|
||||
'ip_recorded' => (bool) config('nntmux_settings.store_user_ip'),
|
||||
],
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
$auditService->record(
|
||||
event: 'request_submitted',
|
||||
description: 'Self-service GDPR export request submitted.',
|
||||
subject: $user,
|
||||
actor: $user,
|
||||
request: $gdprRequest,
|
||||
metadata: ['type' => GdprRequest::TYPE_EXPORT]
|
||||
);
|
||||
|
||||
$exportService->generate($user, $gdprRequest, $user);
|
||||
|
||||
return redirect()
|
||||
->route('privacy-center.index')
|
||||
->with('success', 'Your data export has been generated and is available for 7 days.');
|
||||
}
|
||||
|
||||
public function requestErasure(Request $request, GdprAuditService $auditService, GdprNotificationService $notificationService): RedirectResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->hasRole('Admin')) {
|
||||
return redirect()
|
||||
->route('privacy-center.index')
|
||||
->with('error', 'Admin accounts cannot submit self-service erasure requests.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'confirmation' => ['required', 'string', 'in:ERASE'],
|
||||
'notes' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$openRequestExists = GdprRequest::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('type', GdprRequest::TYPE_ERASURE)
|
||||
->open()
|
||||
->exists();
|
||||
|
||||
if ($openRequestExists) {
|
||||
return redirect()
|
||||
->route('privacy-center.index')
|
||||
->with('error', 'You already have an open account erasure request.');
|
||||
}
|
||||
|
||||
$gdprRequest = GdprRequest::create([
|
||||
'user_id' => $user->id,
|
||||
'requester_username' => $user->username,
|
||||
'requester_email' => $user->email,
|
||||
'type' => GdprRequest::TYPE_ERASURE,
|
||||
'status' => GdprRequest::STATUS_PENDING,
|
||||
'request_payload' => [
|
||||
'requested_by' => 'self_service',
|
||||
'confirmation' => $validated['confirmation'],
|
||||
],
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
$auditService->record(
|
||||
event: 'request_submitted',
|
||||
description: 'Self-service GDPR erasure request submitted.',
|
||||
subject: $user,
|
||||
actor: $user,
|
||||
request: $gdprRequest,
|
||||
metadata: ['type' => GdprRequest::TYPE_ERASURE]
|
||||
);
|
||||
|
||||
$notificationService->requestSubmitted($gdprRequest);
|
||||
|
||||
return redirect()
|
||||
->route('privacy-center.index')
|
||||
->with('success', 'Your account erasure request has been submitted for administrator review.');
|
||||
}
|
||||
|
||||
public function downloadExport(GdprRequest $gdprRequest): StreamedResponse|RedirectResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
if ((int) $gdprRequest->user_id !== (int) $user->id || ! $gdprRequest->isDownloadableExport()) {
|
||||
return redirect()->route('privacy-center.index')->with('error', 'The requested export is not available.');
|
||||
}
|
||||
|
||||
$disk = $gdprRequest->export_disk ?: 'local';
|
||||
if (! Storage::disk($disk)->exists($gdprRequest->export_path)) {
|
||||
return redirect()->route('privacy-center.index')->with('error', 'The requested export file has expired or is missing.');
|
||||
}
|
||||
|
||||
return Storage::disk($disk)->download($gdprRequest->export_path, 'gdpr-export-'.$user->id.'.json');
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateThemeRequest;
|
||||
use App\Jobs\SendAccountDeletedEmail;
|
||||
use App\Models\GdprRequest;
|
||||
use App\Models\ReleaseComment;
|
||||
use App\Models\RootCategory;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDownload;
|
||||
use App\Models\UserRequest;
|
||||
use App\Rules\ValidEmailDomain;
|
||||
use App\Services\Gdpr\GdprErasureService;
|
||||
use App\Support\PermissionSyncHelper;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -248,7 +250,7 @@ class ProfileController extends BasePageController
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function destroy(Request $request): Application|View|Factory|RedirectResponse|\Illuminate\Contracts\Foundation\Application
|
||||
public function destroy(Request $request, GdprErasureService $erasureService): Application|View|Factory|RedirectResponse|\Illuminate\Contracts\Foundation\Application
|
||||
{
|
||||
$userId = $request->input('id');
|
||||
|
||||
@@ -257,12 +259,28 @@ class ProfileController extends BasePageController
|
||||
if ($user === null) {
|
||||
return redirect()->to('profile');
|
||||
}
|
||||
|
||||
$gdprRequest = GdprRequest::create([
|
||||
'user_id' => $user->id,
|
||||
'requester_username' => $user->username,
|
||||
'requester_email' => $user->email,
|
||||
'type' => GdprRequest::TYPE_ERASURE,
|
||||
'status' => GdprRequest::STATUS_PROCESSING,
|
||||
'request_payload' => [
|
||||
'requested_by' => 'profile_delete',
|
||||
'confirmation' => 'legacy_profile_delete_link',
|
||||
],
|
||||
]);
|
||||
|
||||
SendAccountDeletedEmail::dispatch($user);
|
||||
$erasureService->eraseForAccountDeletion($user, $user, $gdprRequest);
|
||||
Auth::logout();
|
||||
$user->delete();
|
||||
|
||||
return redirect()->to('/')->with('success', 'Your account has been deleted. Retained payment and audit records have been anonymized or minimized where practical.');
|
||||
}
|
||||
|
||||
if ($this->userdata->hasRole('Admin')) {
|
||||
|
||||
return redirect()->to('profile');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\HasBrandedSubject;
|
||||
use App\Models\GdprRequest;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Throwable;
|
||||
|
||||
class GdprRequestStatusMail extends Mailable
|
||||
{
|
||||
use HasBrandedSubject, Queueable, SerializesModels;
|
||||
|
||||
public string $site;
|
||||
|
||||
public string $requestType;
|
||||
|
||||
public string $status;
|
||||
|
||||
public string $heading;
|
||||
|
||||
public string $body;
|
||||
|
||||
public string $requester;
|
||||
|
||||
public string $requestedAt;
|
||||
|
||||
public ?string $completedAt;
|
||||
|
||||
public ?string $adminNotes;
|
||||
|
||||
public ?string $actionUrl;
|
||||
|
||||
public ?string $actionText;
|
||||
|
||||
public ?string $preheader;
|
||||
|
||||
private string $siteEmail;
|
||||
|
||||
private string $subjectLine;
|
||||
|
||||
public function __construct(GdprRequest $gdprRequest, string $event, bool $adminCopy = false)
|
||||
{
|
||||
$this->siteEmail = (string) config('mail.from.address');
|
||||
$this->site = (string) config('app.name');
|
||||
$this->requestType = ucfirst(str_replace('_', ' ', $gdprRequest->type));
|
||||
$this->status = ucfirst(str_replace('_', ' ', $gdprRequest->status));
|
||||
$this->requester = trim(($gdprRequest->requester_username ?: 'Unknown user').' <'.($gdprRequest->requester_email ?: 'unknown email').'>');
|
||||
$this->requestedAt = $gdprRequest->created_at === null ? 'N/A' : Carbon::parse($gdprRequest->created_at)->format('Y-m-d H:i T');
|
||||
$this->completedAt = $gdprRequest->completed_at === null ? null : Carbon::parse($gdprRequest->completed_at)->format('Y-m-d H:i T');
|
||||
$this->adminNotes = $gdprRequest->admin_notes;
|
||||
|
||||
[$this->subjectLine, $this->heading, $this->body, $this->actionText, $this->actionUrl] = $this->contentFor($gdprRequest, $event, $adminCopy);
|
||||
$this->preheader = $this->body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function build(): static
|
||||
{
|
||||
return $this->from($this->siteEmail)
|
||||
->brandedSubject($this->subjectLine)
|
||||
->markdown('emails.markdown.gdprRequestStatus', [
|
||||
'site' => $this->site,
|
||||
'requestType' => $this->requestType,
|
||||
'status' => $this->status,
|
||||
'heading' => $this->heading,
|
||||
'body' => $this->body,
|
||||
'requester' => $this->requester,
|
||||
'requestedAt' => $this->requestedAt,
|
||||
'completedAt' => $this->completedAt,
|
||||
'adminNotes' => $this->adminNotes,
|
||||
'actionUrl' => $this->actionUrl,
|
||||
'actionText' => $this->actionText,
|
||||
'preheader' => $this->preheader,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string, 2: string, 3: ?string, 4: ?string}
|
||||
*/
|
||||
private function contentFor(GdprRequest $gdprRequest, string $event, bool $adminCopy): array
|
||||
{
|
||||
if ($adminCopy) {
|
||||
return [
|
||||
'New GDPR '.$this->requestType.' request',
|
||||
'New GDPR request submitted',
|
||||
'A GDPR '.$this->requestType.' request was submitted and may require administrator review.',
|
||||
'Review request',
|
||||
$this->safeRoute('admin.gdpr-requests.show', [$gdprRequest]),
|
||||
];
|
||||
}
|
||||
|
||||
return match ($event) {
|
||||
'submitted' => [
|
||||
'GDPR '.$this->requestType.' request received',
|
||||
'We received your GDPR request',
|
||||
'Your GDPR '.$this->requestType.' request has been received and recorded.',
|
||||
'Open Privacy Center',
|
||||
$this->safeRoute('privacy-center.index'),
|
||||
],
|
||||
'export_ready' => [
|
||||
'Your GDPR data export is ready',
|
||||
'Your data export is ready',
|
||||
'Your GDPR data export has been generated and is available for download until its listed expiry time.',
|
||||
'Download export',
|
||||
$gdprRequest->isDownloadableExport()
|
||||
? $this->safeRoute('privacy-center.export.download', [$gdprRequest])
|
||||
: $this->safeRoute('privacy-center.index'),
|
||||
],
|
||||
'erasure_completed' => [
|
||||
'Your GDPR erasure request is complete',
|
||||
'Your account erasure is complete',
|
||||
'Your GDPR erasure request has been completed. Account data was removed or anonymized, and retained payment/audit records were anonymized or minimized where practical.',
|
||||
null,
|
||||
null,
|
||||
],
|
||||
'rejected' => [
|
||||
'Your GDPR request was rejected',
|
||||
'Your GDPR request was rejected',
|
||||
'Your GDPR '.$this->requestType.' request was reviewed and rejected. See the notes below for the reason provided by the administrator.',
|
||||
'Open Privacy Center',
|
||||
$this->safeRoute('privacy-center.index'),
|
||||
],
|
||||
default => [
|
||||
'GDPR request update',
|
||||
'GDPR request update',
|
||||
'There has been an update to your GDPR '.$this->requestType.' request.',
|
||||
'Open Privacy Center',
|
||||
$this->safeRoute('privacy-center.index'),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $parameters
|
||||
*/
|
||||
private function safeRoute(string $name, array $parameters = []): ?string
|
||||
{
|
||||
try {
|
||||
return route($name, $parameters);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GdprAuditLog extends Model
|
||||
{
|
||||
const UPDATED_AT = null;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'gdpr_request_id',
|
||||
'user_id',
|
||||
'actor_id',
|
||||
'event',
|
||||
'description',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<GdprRequest, $this>
|
||||
*/
|
||||
public function gdprRequest(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(GdprRequest::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_id')->withTrashed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class GdprRequest extends Model
|
||||
{
|
||||
public const TYPE_EXPORT = 'export';
|
||||
|
||||
public const TYPE_ERASURE = 'erasure';
|
||||
|
||||
public const TYPE_RECTIFICATION = 'rectification';
|
||||
|
||||
public const TYPE_RESTRICTION = 'restriction';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'requester_username',
|
||||
'requester_email',
|
||||
'type',
|
||||
'status',
|
||||
'request_payload',
|
||||
'response_payload',
|
||||
'notes',
|
||||
'admin_notes',
|
||||
'export_disk',
|
||||
'export_path',
|
||||
'export_expires_at',
|
||||
'processed_by',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'request_payload' => 'array',
|
||||
'response_payload' => 'array',
|
||||
'export_expires_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function processor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'processed_by')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<GdprAuditLog, $this>
|
||||
*/
|
||||
public function auditLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(GdprAuditLog::class)->latest();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<GdprRequest> $query
|
||||
* @return Builder<GdprRequest>
|
||||
*/
|
||||
public function scopeOpen(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_PENDING, self::STATUS_PROCESSING]);
|
||||
}
|
||||
|
||||
public function isDownloadableExport(): bool
|
||||
{
|
||||
$expiresAt = $this->export_expires_at;
|
||||
|
||||
return $this->type === self::TYPE_EXPORT
|
||||
&& $this->status === self::STATUS_COMPLETED
|
||||
&& $this->export_path !== null
|
||||
&& ($expiresAt === null || Carbon::parse($expiresAt)->isFuture());
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ class UserActivityObserver
|
||||
'username' => $user->username,
|
||||
'activity_type' => 'registered',
|
||||
'description' => "New user registered: {$user->username}",
|
||||
'metadata' => [
|
||||
'metadata' => array_filter([
|
||||
'email' => $user->email,
|
||||
'ip_address' => request()->ip(),
|
||||
],
|
||||
'ip_address' => config('nntmux_settings.store_user_ip') ? request()->ip() : null,
|
||||
], static fn ($value): bool => $value !== null),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Gdpr;
|
||||
|
||||
class GdprDataInventory
|
||||
{
|
||||
/**
|
||||
* Tables retained for legal, accounting, security, or accountability reasons.
|
||||
* Direct account identifiers are anonymized/minimized during erasure whenever practical.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function retainedRecords(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'table' => 'payments',
|
||||
'reason' => 'Accounting, fraud prevention, dispute handling, and legal/tax obligations; direct account identifiers are anonymized while transaction details are retained.',
|
||||
'erasure_action' => 'retained_anonymized',
|
||||
],
|
||||
[
|
||||
'table' => 'paypal_payments',
|
||||
'reason' => 'Legacy payment audit data retained for accounting and dispute handling; linked account identity resolves only to the anonymized/deleted account record.',
|
||||
'erasure_action' => 'retained_minimized',
|
||||
],
|
||||
[
|
||||
'table' => 'user_activities',
|
||||
'reason' => 'Security and administrative audit trail.',
|
||||
'erasure_action' => 'retained_minimized',
|
||||
],
|
||||
[
|
||||
'table' => 'gdpr_audit_logs',
|
||||
'reason' => 'GDPR accountability record for data subject requests.',
|
||||
'erasure_action' => 'retained_minimized',
|
||||
],
|
||||
[
|
||||
'table' => 'user_role_history',
|
||||
'reason' => 'Administrative audit trail for role changes, upgrades, expirations, and account security reviews.',
|
||||
'erasure_action' => 'retained_minimized',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables where records are removed when an account is erased.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function erasableTables(): array
|
||||
{
|
||||
return [
|
||||
'user_requests' => ['user_column' => 'users_id'],
|
||||
'user_downloads' => ['user_column' => 'users_id'],
|
||||
'dnzb_failures' => ['user_column' => 'users_id'],
|
||||
'users_releases' => ['user_column' => 'users_id'],
|
||||
'user_series' => ['user_column' => 'users_id'],
|
||||
'user_movies' => ['user_column' => 'users_id'],
|
||||
'user_excluded_categories' => ['user_column' => 'users_id'],
|
||||
'user_invitations' => ['user_column' => 'user_id'],
|
||||
'trusted_devices' => ['user_column' => 'user_id'],
|
||||
'password_securities' => ['user_column' => 'user_id'],
|
||||
'passkeys' => ['user_column' => 'authenticatable_id', 'extra' => ['authenticatable_type' => 'App\\Models\\User']],
|
||||
'model_has_permissions' => ['user_column' => 'model_id', 'extra' => ['model_type' => 'App\\Models\\User']],
|
||||
'model_has_roles' => ['user_column' => 'model_id', 'extra' => ['model_type' => 'App\\Models\\User']],
|
||||
'role_promotion_stats' => ['user_column' => 'user_id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables where public/community records are kept but direct identifiers are minimized.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function anonymizedTables(): array
|
||||
{
|
||||
return [
|
||||
'release_comments' => [
|
||||
'user_column' => 'users_id',
|
||||
'updates' => [
|
||||
'username' => 'Deleted user',
|
||||
'host' => null,
|
||||
],
|
||||
],
|
||||
'release_reports' => [
|
||||
'user_column' => 'users_id',
|
||||
'updates' => [
|
||||
'description' => '[Removed during account erasure]',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tables exported as part of a data access package when present.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function exportTables(): array
|
||||
{
|
||||
return [
|
||||
'user_requests' => ['user_column' => 'users_id', 'order_by' => 'timestamp'],
|
||||
'user_downloads' => ['user_column' => 'users_id', 'order_by' => 'timestamp'],
|
||||
'dnzb_failures' => ['user_column' => 'users_id'],
|
||||
'users_releases' => ['user_column' => 'users_id', 'order_by' => 'created_at'],
|
||||
'user_series' => ['user_column' => 'users_id'],
|
||||
'user_movies' => ['user_column' => 'users_id'],
|
||||
'user_excluded_categories' => ['user_column' => 'users_id'],
|
||||
'user_invitations' => ['user_column' => 'user_id', 'order_by' => 'created_at'],
|
||||
'release_comments' => ['user_column' => 'users_id', 'order_by' => 'created_at'],
|
||||
'release_reports' => ['user_column' => 'users_id', 'order_by' => 'created_at'],
|
||||
'invitations_sent' => ['table' => 'invitations', 'user_column' => 'invited_by', 'order_by' => 'created_at'],
|
||||
'invitations_used' => ['table' => 'invitations', 'user_column' => 'used_by', 'order_by' => 'used_at'],
|
||||
'trusted_devices' => ['user_column' => 'user_id', 'order_by' => 'created_at'],
|
||||
'role_history' => ['table' => 'user_role_history', 'user_column' => 'user_id', 'order_by' => 'created_at'],
|
||||
'promotion_stats' => ['table' => 'role_promotion_stats', 'user_column' => 'user_id', 'order_by' => 'created_at'],
|
||||
'gdpr_requests' => ['user_column' => 'user_id', 'order_by' => 'created_at'],
|
||||
'gdpr_consents' => ['user_column' => 'user_id', 'order_by' => 'created_at'],
|
||||
'gdpr_audit_logs' => ['user_column' => 'user_id', 'order_by' => 'created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
public function essentialCookies(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'session', 'purpose' => 'Keeps you signed in and stores security/session state.', 'type' => 'essential'],
|
||||
['name' => 'XSRF-TOKEN', 'purpose' => 'Protects forms and authenticated requests from cross-site request forgery.', 'type' => 'essential'],
|
||||
['name' => '2fa_trusted_device', 'purpose' => 'Optional security cookie used only when a user chooses to trust a device for two-factor authentication.', 'type' => 'essential_security'],
|
||||
['name' => 'theme/color local storage', 'purpose' => 'Stores display preferences for guests; authenticated preferences are stored on the account.', 'type' => 'essential_preference'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Gdpr;
|
||||
|
||||
use App\Models\GdprRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\AdminDashboardSnapshotService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GdprErasureService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GdprDataInventory $inventory = new GdprDataInventory,
|
||||
private readonly GdprAuditService $audit = new GdprAuditService,
|
||||
private readonly GdprNotificationService $notifications = new GdprNotificationService,
|
||||
) {}
|
||||
|
||||
public function eraseForAccountDeletion(User $user, ?User $actor = null, ?GdprRequest $request = null, bool $forceDelete = false): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $actor, $request, $forceDelete): void {
|
||||
$freshUser = User::withTrashed()->findOrFail($user->id);
|
||||
$original = [
|
||||
'id' => (int) $freshUser->id,
|
||||
'username' => (string) $freshUser->username,
|
||||
'email' => (string) $freshUser->email,
|
||||
];
|
||||
$anonymous = $this->anonymousIdentity((int) $freshUser->id);
|
||||
|
||||
$this->deleteErasableRecords((int) $freshUser->id);
|
||||
$this->anonymizeCommunityRecords((int) $freshUser->id, $anonymous['username']);
|
||||
$this->minimizeInvitations((int) $freshUser->id, $original['email'], $anonymous['email']);
|
||||
$this->anonymizeRetainedPaymentRecords($original, $anonymous);
|
||||
$this->anonymizeRetainedAuditRecords($original, $anonymous);
|
||||
$this->anonymizeUser($freshUser, $anonymous);
|
||||
|
||||
$this->audit->record(
|
||||
event: $forceDelete ? 'erasure_force_delete' : 'erasure_soft_delete',
|
||||
description: 'GDPR erasure workflow completed; retained payment and audit records were anonymized/minimized.',
|
||||
subject: $freshUser,
|
||||
actor: $actor,
|
||||
request: $request,
|
||||
metadata: [
|
||||
'retained_records' => $this->inventory->retainedRecords(),
|
||||
'force_delete' => $forceDelete,
|
||||
]
|
||||
);
|
||||
|
||||
if ($forceDelete) {
|
||||
$freshUser->forceDelete();
|
||||
} elseif (! $freshUser->trashed()) {
|
||||
$freshUser->delete();
|
||||
}
|
||||
|
||||
if ($request !== null) {
|
||||
$request->update([
|
||||
'status' => GdprRequest::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
'processed_by' => $actor?->id,
|
||||
'response_payload' => [
|
||||
'erasure' => 'completed',
|
||||
'force_delete' => $forceDelete,
|
||||
'retained_records' => $this->inventory->retainedRecords(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}, 3);
|
||||
|
||||
if ($request !== null) {
|
||||
$this->notifications->erasureCompleted($request->fresh() ?? $request);
|
||||
}
|
||||
|
||||
Cache::forget(AdminDashboardSnapshotService::CACHE_KEY);
|
||||
}
|
||||
|
||||
public function forceDeleteWithErasure(User $user, ?User $actor = null, ?GdprRequest $request = null): void
|
||||
{
|
||||
$this->eraseForAccountDeletion($user, $actor, $request, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{username: string, email: string, name: string}
|
||||
*/
|
||||
private function anonymousIdentity(int $userId): array
|
||||
{
|
||||
return [
|
||||
'username' => 'deleted_user_'.$userId,
|
||||
'email' => 'deleted-user-'.$userId.'@deleted.invalid',
|
||||
'name' => 'Deleted user '.$userId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{username: string, email: string, name: string} $anonymous
|
||||
*/
|
||||
private function anonymizeUser(User $user, array $anonymous): void
|
||||
{
|
||||
$updates = [
|
||||
'username' => $anonymous['username'],
|
||||
'email' => $anonymous['email'],
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
'remember_token' => null,
|
||||
'session_token' => null,
|
||||
'api_token' => hash('sha256', 'deleted-'.$user->id.'-'.Str::random(32)),
|
||||
'resetguid' => null,
|
||||
'verification_token' => null,
|
||||
'firstname' => null,
|
||||
'lastname' => null,
|
||||
'name' => $anonymous['name'],
|
||||
'host' => '',
|
||||
'notes' => null,
|
||||
'saburl' => null,
|
||||
'sabapikey' => null,
|
||||
'nzbgeturl' => null,
|
||||
'nzbgetusername' => null,
|
||||
'nzbgetpassword' => null,
|
||||
'nzbvortex_api_key' => null,
|
||||
'nzbvortex_server_url' => null,
|
||||
'cp_url' => null,
|
||||
'cp_api' => null,
|
||||
'email_verified_at' => null,
|
||||
'verified' => false,
|
||||
'can_post' => false,
|
||||
'invites' => 0,
|
||||
];
|
||||
|
||||
$updates = $this->filterColumns('users', $updates);
|
||||
|
||||
User::withoutEvents(function () use ($user, $updates): void {
|
||||
User::withTrashed()->where('id', $user->id)->update($updates);
|
||||
});
|
||||
|
||||
$user->forceFill($updates);
|
||||
}
|
||||
|
||||
private function deleteErasableRecords(int $userId): void
|
||||
{
|
||||
foreach ($this->inventory->erasableTables() as $table => $definition) {
|
||||
$userColumn = $definition['user_column'];
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $userColumn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = DB::table($table)->where($userColumn, $userId);
|
||||
|
||||
foreach (($definition['extra'] ?? []) as $column => $value) {
|
||||
if (Schema::hasColumn($table, (string) $column)) {
|
||||
$query->where((string) $column, $value);
|
||||
}
|
||||
}
|
||||
|
||||
$query->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function anonymizeCommunityRecords(int $userId, string $anonymousUsername): void
|
||||
{
|
||||
foreach ($this->inventory->anonymizedTables() as $table => $definition) {
|
||||
$userColumn = $definition['user_column'];
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $userColumn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$updates = $this->filterColumns($table, $definition['updates'] ?? []);
|
||||
if ($updates === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn($table, 'username')) {
|
||||
$updates['username'] = $anonymousUsername;
|
||||
}
|
||||
|
||||
DB::table($table)->where($userColumn, $userId)->update($updates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: int, username: string, email: string} $original
|
||||
* @param array{username: string, email: string, name: string} $anonymous
|
||||
*/
|
||||
private function anonymizeRetainedPaymentRecords(array $original, array $anonymous): void
|
||||
{
|
||||
if (! Schema::hasTable('payments')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = $this->filterColumns('payments', [
|
||||
'email' => $anonymous['email'],
|
||||
'username' => $anonymous['username'],
|
||||
]);
|
||||
|
||||
if ($updates === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('payments')
|
||||
->where(function ($query) use ($original): void {
|
||||
if (Schema::hasColumn('payments', 'email')) {
|
||||
$query->orWhere('email', $original['email']);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('payments', 'username')) {
|
||||
$query->orWhere('username', $original['username']);
|
||||
}
|
||||
})
|
||||
->update($updates);
|
||||
}
|
||||
|
||||
private function minimizeInvitations(int $userId, string $originalEmail, string $anonymousEmail): void
|
||||
{
|
||||
if (! Schema::hasTable('invitations')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('invitations', 'email')) {
|
||||
DB::table('invitations')
|
||||
->where('email', $originalEmail)
|
||||
->update(['email' => $anonymousEmail]);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('invitations', 'used_by')) {
|
||||
DB::table('invitations')
|
||||
->where('used_by', $userId)
|
||||
->update(['used_by' => null]);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('invitations', 'invited_by') && Schema::hasColumn('invitations', 'used_at')) {
|
||||
DB::table('invitations')
|
||||
->where('invited_by', $userId)
|
||||
->whereNull('used_at')
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: int, username: string, email: string} $original
|
||||
* @param array{username: string, email: string, name: string} $anonymous
|
||||
*/
|
||||
private function anonymizeRetainedAuditRecords(array $original, array $anonymous): void
|
||||
{
|
||||
if (Schema::hasTable('user_activities')) {
|
||||
DB::table('user_activities')
|
||||
->where(function ($query) use ($original): void {
|
||||
$query->where('user_id', $original['id'])
|
||||
->orWhere('username', $original['username']);
|
||||
})
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($activities) use ($anonymous): void {
|
||||
foreach ($activities as $activity) {
|
||||
$metadata = $this->sanitizeMetadata($activity->metadata ?? null);
|
||||
$updates = ['username' => $anonymous['username']];
|
||||
if (Schema::hasColumn('user_activities', 'metadata')) {
|
||||
$updates['metadata'] = json_encode($metadata, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
DB::table('user_activities')->where('id', $activity->id)->update($updates);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('gdpr_audit_logs') && Schema::hasColumn('gdpr_audit_logs', 'metadata')) {
|
||||
DB::table('gdpr_audit_logs')
|
||||
->where('user_id', $original['id'])
|
||||
->update(['metadata' => json_encode(['subject' => $anonymous['username']], JSON_UNESCAPED_SLASHES)]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeMetadata(mixed $metadata): array
|
||||
{
|
||||
if (is_string($metadata)) {
|
||||
$decoded = json_decode($metadata, true);
|
||||
$metadata = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
if (! is_array($metadata)) {
|
||||
$metadata = [];
|
||||
}
|
||||
|
||||
unset($metadata['email'], $metadata['ip_address'], $metadata['host']);
|
||||
$metadata['gdpr_minimized'] = true;
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $updates
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function filterColumns(string $table, array $updates): array
|
||||
{
|
||||
if (! Schema::hasTable($table)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
$updates,
|
||||
static fn (string $column): bool => Schema::hasColumn($table, $column),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Gdpr;
|
||||
|
||||
use App\Models\GdprRequest;
|
||||
use App\Models\Payment;
|
||||
use App\Models\User;
|
||||
use App\Models\UserActivity;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class GdprExportService
|
||||
{
|
||||
private const EXPORT_DISK = 'local';
|
||||
|
||||
/**
|
||||
* Secrets and operational tokens are not included in data portability exports.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const REDACTED_USER_COLUMNS = [
|
||||
'password',
|
||||
'remember_token',
|
||||
'session_token',
|
||||
'api_token',
|
||||
'resetguid',
|
||||
'verification_token',
|
||||
'sabapikey',
|
||||
'nzbgetpassword',
|
||||
'nzbvortex_api_key',
|
||||
'cp_api',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly GdprDataInventory $inventory = new GdprDataInventory,
|
||||
private readonly GdprAuditService $audit = new GdprAuditService,
|
||||
private readonly GdprNotificationService $notifications = new GdprNotificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate and persist a JSON data export for a GDPR access/data portability request.
|
||||
*
|
||||
* @return array{disk: string, path: string, expires_at: Carbon}
|
||||
*/
|
||||
public function generate(User $user, ?GdprRequest $request = null, ?User $actor = null): array
|
||||
{
|
||||
$payload = $this->buildPayload($user);
|
||||
$encoded = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$path = 'gdpr/exports/user-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(12).'.json';
|
||||
|
||||
Storage::disk(self::EXPORT_DISK)->put($path, $encoded);
|
||||
|
||||
$expiresAt = now()->addDays(7);
|
||||
|
||||
if ($request !== null) {
|
||||
$request->update([
|
||||
'status' => GdprRequest::STATUS_COMPLETED,
|
||||
'export_disk' => self::EXPORT_DISK,
|
||||
'export_path' => $path,
|
||||
'export_expires_at' => $expiresAt,
|
||||
'completed_at' => now(),
|
||||
'response_payload' => [
|
||||
'format' => 'json',
|
||||
'retained_records' => $this->inventory->retainedRecords(),
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->audit->record(
|
||||
event: 'export_generated',
|
||||
description: 'GDPR data export generated.',
|
||||
subject: $user,
|
||||
actor: $actor,
|
||||
request: $request,
|
||||
metadata: ['path' => $path, 'expires_at' => $expiresAt->toIso8601String()]
|
||||
);
|
||||
|
||||
if ($request !== null) {
|
||||
$this->notifications->exportReady($request->fresh() ?? $request);
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => self::EXPORT_DISK,
|
||||
'path' => $path,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildPayload(User $user): array
|
||||
{
|
||||
return [
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'format_version' => 1,
|
||||
'subject' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'email' => $user->email,
|
||||
],
|
||||
'account' => $this->exportAccount($user),
|
||||
'related_records' => $this->exportRelatedTables($user),
|
||||
'retained_audit_records' => $this->exportAuditRecords($user),
|
||||
'retained_payment_records' => $this->exportPaymentRecords($user),
|
||||
'retention_notice' => [
|
||||
'summary' => 'Payment and audit records may be retained after erasure where required for legal, accounting, security, dispute, or GDPR accountability purposes, with direct account identifiers anonymized or minimized where practical.',
|
||||
'records' => $this->inventory->retainedRecords(),
|
||||
],
|
||||
'essential_cookies' => $this->inventory->essentialCookies(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function exportAccount(User $user): array
|
||||
{
|
||||
$attributes = $user->getAttributes();
|
||||
|
||||
foreach (self::REDACTED_USER_COLUMNS as $column) {
|
||||
unset($attributes[$column]);
|
||||
}
|
||||
|
||||
$attributes['redacted_columns'] = self::REDACTED_USER_COLUMNS;
|
||||
$attributes['roles'] = $user->roles()->pluck('name')->all();
|
||||
|
||||
if (Schema::hasTable('passkeys')) {
|
||||
$attributes['passkeys'] = $this->tableRows('passkeys', 'authenticatable_id', (int) $user->id, redactedColumns: ['credential_id', 'data']);
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function exportRelatedTables(User $user): array
|
||||
{
|
||||
$records = [];
|
||||
|
||||
foreach ($this->inventory->exportTables() as $key => $definition) {
|
||||
$table = $definition['table'] ?? $key;
|
||||
$userColumn = $definition['user_column'];
|
||||
$orderBy = $definition['order_by'] ?? null;
|
||||
|
||||
$records[$key] = $this->tableRows($table, $userColumn, (int) $user->id, $orderBy);
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function exportAuditRecords(User $user): array
|
||||
{
|
||||
if (! Schema::hasTable('user_activities')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return UserActivity::query()
|
||||
->where('user_id', $user->id)
|
||||
->orWhere('username', $user->username)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn (UserActivity $activity): array => $this->modelToArray($activity))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function exportPaymentRecords(User $user): array
|
||||
{
|
||||
if (! Schema::hasTable('payments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Payment::query()
|
||||
->where('email', $user->email)
|
||||
->orWhere('username', $user->username)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn (Payment $payment): array => $this->modelToArray($payment))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $redactedColumns
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function tableRows(string $table, string $userColumn, int $userId, ?string $orderBy = null, array $redactedColumns = []): array
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $userColumn)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = DB::table($table)->where($userColumn, $userId);
|
||||
|
||||
if ($orderBy !== null && Schema::hasColumn($table, $orderBy)) {
|
||||
$query->orderByDesc($orderBy);
|
||||
}
|
||||
|
||||
return $query->get()
|
||||
->map(fn (object $row): array => $this->rowToArray($row, $redactedColumns))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $redactedColumns
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rowToArray(object $row, array $redactedColumns = []): array
|
||||
{
|
||||
$data = json_decode(json_encode($row, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
foreach ($redactedColumns as $column) {
|
||||
if (array_key_exists($column, $data)) {
|
||||
$data[$column] = '[redacted]';
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function modelToArray(object $model): array
|
||||
{
|
||||
return json_decode(json_encode($model, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Gdpr;
|
||||
|
||||
use App\Mail\GdprRequestStatusMail;
|
||||
use App\Models\GdprRequest;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class GdprNotificationService
|
||||
{
|
||||
public function requestSubmitted(GdprRequest $gdprRequest): void
|
||||
{
|
||||
$this->sendToRequester($gdprRequest, 'submitted');
|
||||
|
||||
if ($gdprRequest->type === GdprRequest::TYPE_ERASURE) {
|
||||
$this->sendToAdmin($gdprRequest, 'submitted');
|
||||
}
|
||||
}
|
||||
|
||||
public function exportReady(GdprRequest $gdprRequest): void
|
||||
{
|
||||
$this->sendToRequester($gdprRequest, 'export_ready');
|
||||
}
|
||||
|
||||
public function erasureCompleted(GdprRequest $gdprRequest): void
|
||||
{
|
||||
$this->sendToRequester($gdprRequest, 'erasure_completed');
|
||||
}
|
||||
|
||||
public function requestRejected(GdprRequest $gdprRequest): void
|
||||
{
|
||||
$this->sendToRequester($gdprRequest, 'rejected');
|
||||
}
|
||||
|
||||
private function sendToRequester(GdprRequest $gdprRequest, string $event): void
|
||||
{
|
||||
$email = trim((string) $gdprRequest->requester_email);
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($email)->send(new GdprRequestStatusMail($gdprRequest, $event));
|
||||
}
|
||||
|
||||
private function sendToAdmin(GdprRequest $gdprRequest, string $event): void
|
||||
{
|
||||
$email = trim((string) config('mail.from.address'));
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($email)->send(new GdprRequestStatusMail($gdprRequest, $event, adminCopy: true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Gdpr;
|
||||
|
||||
use App\Models\GdprRequest;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class GdprRetentionService
|
||||
{
|
||||
public function __construct(private readonly GdprDataInventory $inventory = new GdprDataInventory) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function policy(): array
|
||||
{
|
||||
return [
|
||||
'export_files' => '7 days after generation, then safe to purge.',
|
||||
'gdpr_requests' => 'Retained as an accountability record.',
|
||||
'payments' => 'Retained for accounting, tax, fraud prevention, dispute handling, and legal obligations with direct account identifiers anonymized during erasure where practical.',
|
||||
'audit' => 'Retained in anonymized or minimized form for security and administrative accountability.',
|
||||
'cookies' => 'Essential cookies/storage only; no marketing or analytics cookies are set by GDPR functionality.',
|
||||
'retained_records' => $this->inventory->retainedRecords(),
|
||||
];
|
||||
}
|
||||
|
||||
public function purgeExpiredExports(): int
|
||||
{
|
||||
if (! Schema::hasTable('gdpr_requests')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
GdprRequest::query()
|
||||
->where('type', GdprRequest::TYPE_EXPORT)
|
||||
->whereNotNull('export_path')
|
||||
->whereNotNull('export_expires_at')
|
||||
->where('export_expires_at', '<', now())
|
||||
->chunkById(100, function ($requests) use (&$count): void {
|
||||
foreach ($requests as $request) {
|
||||
Storage::disk($request->export_disk ?: 'local')->delete($request->export_path);
|
||||
$request->update([
|
||||
'export_path' => null,
|
||||
'export_disk' => null,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('content')
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-semibold text-gray-800 dark:text-gray-100">
|
||||
<i class="fas fa-shield-alt mr-2"></i>{{ $title }}
|
||||
</h1>
|
||||
<a href="{{ route('admin.gdpr-requests.index') }}" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
|
||||
<i class="fas fa-arrow-left mr-2"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="mx-6 mt-4 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-green-800 dark:text-green-200">
|
||||
<i class="fas fa-check-circle mr-2"></i>{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="mx-6 mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-200">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
@if($errors->any())
|
||||
<div class="mx-6 mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-200">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-5">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Request Details</h2>
|
||||
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Requester</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100 font-medium">{{ $gdprRequest->requester_username ?? 'N/A' }} <{{ $gdprRequest->requester_email ?? 'N/A' }}></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Subject User</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100 font-medium">
|
||||
@if($subject)
|
||||
{{ $subject->username }} (#{{ $subject->id }}) @if($subject->trashed()) <span class="text-xs text-red-500">deleted</span> @endif
|
||||
@else
|
||||
Missing/deleted
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Type</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100 font-medium">{{ ucfirst($gdprRequest->type) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Status</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100 font-medium">{{ ucfirst($gdprRequest->status) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Created</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100 font-medium">{{ $gdprRequest->created_at?->format('Y-m-d H:i') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Completed</dt>
|
||||
<dd class="text-gray-900 dark:text-gray-100 font-medium">{{ $gdprRequest->completed_at?->format('Y-m-d H:i') ?? '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if($gdprRequest->notes)
|
||||
<div class="mt-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">User Notes</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400 whitespace-pre-line">{{ $gdprRequest->notes }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($gdprRequest->admin_notes)
|
||||
<div class="mt-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Admin Notes</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400 whitespace-pre-line">{{ $gdprRequest->admin_notes }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-5">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Audit Trail</h2>
|
||||
<div class="space-y-3">
|
||||
@forelse($gdprRequest->auditLogs as $auditLog)
|
||||
<div class="border-l-4 border-blue-500 pl-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $auditLog->event }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">{{ $auditLog->description }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-500">{{ $auditLog->created_at?->format('Y-m-d H:i:s') }}</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No GDPR audit events recorded for this request yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-900">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Actions</h2>
|
||||
|
||||
@if($gdprRequest->type === \App\Models\GdprRequest::TYPE_EXPORT && in_array($gdprRequest->status, [\App\Models\GdprRequest::STATUS_PENDING, \App\Models\GdprRequest::STATUS_PROCESSING], true))
|
||||
<form method="post" action="{{ route('admin.gdpr-requests.generate-export', $gdprRequest) }}" class="mb-3">
|
||||
@csrf
|
||||
<button type="submit" class="w-full px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-md hover:bg-blue-700">
|
||||
<i class="fas fa-file-export mr-2"></i>Generate Export
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if($gdprRequest->type === \App\Models\GdprRequest::TYPE_ERASURE && in_array($gdprRequest->status, [\App\Models\GdprRequest::STATUS_PENDING, \App\Models\GdprRequest::STATUS_PROCESSING], true))
|
||||
<form method="post" action="{{ route('admin.gdpr-requests.complete-erasure', $gdprRequest) }}" class="mb-3">
|
||||
@csrf
|
||||
<button type="submit" class="w-full px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-md hover:bg-red-700" data-confirm="Complete GDPR erasure for this account? Retained payment and audit records will be anonymized or minimized where practical.">
|
||||
<i class="fas fa-user-slash mr-2"></i>Complete Erasure
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if(in_array($gdprRequest->status, [\App\Models\GdprRequest::STATUS_PENDING, \App\Models\GdprRequest::STATUS_PROCESSING], true))
|
||||
<form method="post" action="{{ route('admin.gdpr-requests.reject', $gdprRequest) }}">
|
||||
@csrf
|
||||
<label for="admin_notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rejection reason</label>
|
||||
<textarea id="admin_notes" name="admin_notes" rows="4" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md" required></textarea>
|
||||
<button type="submit" class="mt-3 w-full px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-800">
|
||||
<i class="fas fa-ban mr-2"></i>Reject Request
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No actions available for completed, cancelled, or rejected requests.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($gdprRequest->isDownloadableExport())
|
||||
<div class="border border-green-200 dark:border-green-800 rounded-lg p-5 bg-green-50 dark:bg-green-900/10">
|
||||
<h2 class="text-lg font-semibold text-green-800 dark:text-green-200 mb-2">Export Ready</h2>
|
||||
<p class="text-sm text-green-700 dark:text-green-300">The user can download this export until {{ $gdprRequest->export_expires_at?->format('Y-m-d H:i') }}.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deleted By</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">GDPR Erasure</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -214,6 +215,17 @@
|
||||
<span class="text-gray-400 dark:text-gray-500">N/A</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@if($user->gdpr_erasure_request_id)
|
||||
<a href="{{ route('admin.gdpr-requests.show', $user->gdpr_erasure_request_id) }}" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-teal-100 dark:bg-teal-900 text-teal-800 dark:text-teal-200 hover:bg-teal-200 dark:hover:bg-teal-800" title="View completed GDPR erasure request #{{ $user->gdpr_erasure_request_id }}">
|
||||
<i class="fas fa-shield-alt mr-1"></i>GDPR
|
||||
</a>
|
||||
@else
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-minus mr-1"></i>No
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex gap-2">
|
||||
<button type="button"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
@component('mail::message')
|
||||
# {{ $heading }}
|
||||
|
||||
{{ $body }}
|
||||
|
||||
@component('mail::panel')
|
||||
**Request type:** {{ $requestType }}
|
||||
**Status:** {{ $status }}
|
||||
**Requester:** {{ $requester }}
|
||||
**Requested:** {{ $requestedAt }}
|
||||
@if($completedAt)
|
||||
**Completed:** {{ $completedAt }}
|
||||
@endif
|
||||
@endcomponent
|
||||
|
||||
@if($adminNotes)
|
||||
## Administrator notes
|
||||
|
||||
{{ $adminNotes }}
|
||||
@endif
|
||||
|
||||
@if($actionUrl && $actionText)
|
||||
@component('mail::button', ['url' => $actionUrl])
|
||||
{{ $actionText }}
|
||||
@endcomponent
|
||||
@endif
|
||||
|
||||
If you did not request this or have questions, please contact the site administrators.
|
||||
|
||||
— The {{ $site }} System
|
||||
@endcomponent
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
<a href="{{ url('/admin/deleted-users') }}" class="block py-2 px-3 text-gray-400 dark:text-gray-500 hover:text-white dark:hover:text-white hover:bg-white/10 dark:hover:bg-white/5 rounded transition">
|
||||
<i class="fas fa-user-slash mr-2 text-gray-400"></i>Deleted Users
|
||||
</a>
|
||||
<a href="{{ route('admin.gdpr-requests.index') }}" class="block py-2 px-3 text-gray-400 dark:text-gray-500 hover:text-white dark:hover:text-white hover:bg-white/10 dark:hover:bg-white/5 rounded transition">
|
||||
<i class="fas fa-shield-alt mr-2 text-teal-400"></i>GDPR Requests
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
@extends('layouts.main')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-5xl mx-auto space-y-6">
|
||||
<div class="surface-panel rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="surface-panel-alt border-b px-6 py-4">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-600 dark:text-primary-400"></i>Privacy Center
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Export your account data and manage GDPR requests.</p>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="mx-6 mt-6 p-4 bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500 text-green-800 dark:text-green-200 rounded">
|
||||
<i class="fas fa-check-circle mr-2"></i>{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="mx-6 mt-6 p-4 bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 text-red-800 dark:text-red-200 rounded">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($errors->any())
|
||||
<div class="mx-6 mt-6 p-4 bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 text-red-800 dark:text-red-200 rounded">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="p-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-white dark:bg-gray-800">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">
|
||||
<i class="fas fa-file-export mr-2 text-blue-600 dark:text-blue-400"></i>Download Your Data
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Generate a JSON export containing your account profile, usage records, comments, requests, GDPR history, and retained payment/audit records that relate to your account.
|
||||
</p>
|
||||
<form method="post" action="{{ route('privacy-center.export') }}">
|
||||
@csrf
|
||||
<label for="export_notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Optional notes</label>
|
||||
<textarea id="export_notes" name="notes" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md" placeholder="Anything administrators should know?"></textarea>
|
||||
<button type="submit" class="mt-4 px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-md hover:bg-blue-700">
|
||||
<i class="fas fa-download mr-2"></i>Generate Export
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="border border-red-200 dark:border-red-800 rounded-lg p-5 bg-red-50 dark:bg-red-900/10">
|
||||
<h2 class="text-lg font-semibold text-red-800 dark:text-red-200 mb-3">
|
||||
<i class="fas fa-user-slash mr-2"></i>Request Account Erasure
|
||||
</h2>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mb-4">
|
||||
Submit a GDPR erasure request for administrator review. Account/profile data and usage records will be removed or anonymized. Payment and audit records are retained only where required and direct account identifiers are anonymized or minimized where practical.
|
||||
</p>
|
||||
<form method="post" action="{{ route('privacy-center.erasure') }}">
|
||||
@csrf
|
||||
<label for="erasure_notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Optional notes</label>
|
||||
<textarea id="erasure_notes" name="notes" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md" placeholder="Reason or additional details"></textarea>
|
||||
<label for="confirmation" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mt-4 mb-1">Type ERASE to confirm</label>
|
||||
<input id="confirmation" name="confirmation" type="text" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md" autocomplete="off">
|
||||
<button type="submit" class="mt-4 px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-md hover:bg-red-700">
|
||||
<i class="fas fa-paper-plane mr-2"></i>Submit Erasure Request
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-panel rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="surface-panel-alt border-b px-6 py-4">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Your GDPR Requests</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@forelse($gdprRequests as $gdprRequest)
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{{ $gdprRequest->created_at?->format('Y-m-d H:i') }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{{ ucfirst($gdprRequest->type) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200">{{ ucfirst($gdprRequest->status) }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
@if($gdprRequest->isDownloadableExport())
|
||||
<a href="{{ route('privacy-center.export.download', $gdprRequest) }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
<i class="fas fa-download mr-1"></i>Download export
|
||||
</a>
|
||||
<span class="block text-xs text-gray-500 dark:text-gray-500">Expires {{ $gdprRequest->export_expires_at?->diffForHumans() }}</span>
|
||||
@elseif($gdprRequest->admin_notes)
|
||||
{{ $gdprRequest->admin_notes }}
|
||||
@else
|
||||
—
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">No GDPR requests yet.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
{{ $gdprRequests->links() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-panel rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-3">Essential Cookies & Retention</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">This site uses essential cookies/storage only. Payment and audit records may be retained after erasure where required for legal, accounting, security, dispute, or GDPR accountability purposes, with direct account identifiers anonymized or minimized where practical.</p>
|
||||
<ul class="list-disc pl-6 text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||
@foreach($retentionPolicy['retained_records'] as $record)
|
||||
<li><strong>{{ $record['table'] }}:</strong> {{ $record['reason'] }} <span class="text-gray-500">({{ str_replace('_', ' ', $record['erasure_action']) }})</span></li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
<p class="mb-4">When you use {{ config('app.name') }}, we may collect the following types of information:</p>
|
||||
<ul class="list-disc pl-10 ml-4 mb-4">
|
||||
<li><strong>Account Information:</strong> Username, email address, and password when you register.</li>
|
||||
<li><strong>Cookies:</strong> We use cookies to maintain your session and preferences.</li>
|
||||
<li><strong>Usage Information:</strong> API requests, downloads, comments, invitations, security events, and account activity needed to operate and secure the service.</li>
|
||||
<li><strong>Payment Information:</strong> Payment and invoice records when you make a payment or donation.</li>
|
||||
<li><strong>Essential Cookies and Storage:</strong> We use only essential cookies/storage to maintain your session, protect forms, support security features such as two-factor trusted devices, and remember display preferences.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-4 mt-6">2. How We Use Your Information</h2>
|
||||
@@ -71,27 +73,31 @@
|
||||
<li>Resolve disputes</li>
|
||||
</ul>
|
||||
<p class="mb-4">When you delete your account, we will delete or anonymize your personal information, unless we are required to retain it for legal purposes.</p>
|
||||
<p class="mb-4">Payment records and security/admin audit records may be retained where needed for accounting, tax, fraud prevention, dispute handling, security, or GDPR accountability. During account erasure, direct account identifiers in retained payment and audit records are anonymized, minimized, or pseudonymized where practical while transaction/accountability details needed for those purposes are preserved.</p>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-4 mt-6">6. Your Rights and Choices</h2>
|
||||
<p class="mb-4">You have the following rights regarding your personal information:</p>
|
||||
<ul class="list-disc pl-10 ml-4 mb-4">
|
||||
<li><strong>Access:</strong> Request access to the personal information we hold about you</li>
|
||||
<li><strong>Portability:</strong> Request an export of your account data in a machine-readable format</li>
|
||||
<li><strong>Correction:</strong> Request correction of inaccurate or incomplete information</li>
|
||||
<li><strong>Deletion:</strong> Request deletion of your account and associated data</li>
|
||||
<li><strong>Restriction or Objection:</strong> Ask us to restrict or review certain processing where applicable</li>
|
||||
</ul>
|
||||
<p class="mb-4">To exercise these rights, please contact us through your account settings or the contact methods provided on our website.</p>
|
||||
<p class="mb-4">To exercise these rights, use the Privacy Center in your account or contact us through the contact methods provided on our website.</p>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-4 mt-6">7. Cookies and Tracking Technologies</h2>
|
||||
<p class="mb-4">We use cookies and similar tracking technologies to:</p>
|
||||
<p class="mb-4">We use essential cookies and similar browser storage only. We do not use marketing or analytics cookies by default. Essential cookies/storage are used to:</p>
|
||||
<ul class="list-disc pl-10 ml-4 mb-4">
|
||||
<li>Maintain your login session</li>
|
||||
<li>Remember your preferences (such as dark mode settings)</li>
|
||||
<li>Analyze site usage and performance</li>
|
||||
<li>Protect forms and authenticated requests from cross-site request forgery</li>
|
||||
<li>Support security features such as optional two-factor trusted devices</li>
|
||||
<li>Remember display preferences such as theme and color scheme</li>
|
||||
</ul>
|
||||
<p class="mb-4">You can control cookie settings through your browser preferences. Note that disabling cookies may affect the functionality of our service.</p>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-4 mt-6">8. Third-Party Services</h2>
|
||||
<p class="mb-4">Our service may contain links to third-party websites or integrate with third-party services. We are not responsible for the privacy practices of these third parties. We encourage you to review their privacy policies before providing any personal information.</p>
|
||||
<p class="mb-4">Our service may contain links to third-party websites or integrate with third-party services such as captcha providers, Gravatar avatars, payment processors, and error monitoring. We are not responsible for the privacy practices of these third parties. We encourage you to review their privacy policies before providing any personal information.</p>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-4 mt-6">9. Children's Privacy</h2>
|
||||
<p class="mb-4">Our service is not intended for users under the age of 18. We do not knowingly collect personal information from children. If we become aware that we have collected personal information from a child without parental consent, we will take steps to delete that information.</p>
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
<i class="fa fa-edit mr-1"></i>Edit Profile
|
||||
</a>
|
||||
@endif
|
||||
@if(!($isadmin ?? false) && !$publicview)
|
||||
<a href="{{ route('privacy-center.index') }}" class="px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white text-sm rounded hover:bg-blue-700 dark:hover:bg-blue-800 transition">
|
||||
<i class="fa fa-shield-alt mr-1"></i>Privacy Center
|
||||
</a>
|
||||
@endif
|
||||
@if(!($isadmin ?? false) && !$publicview)
|
||||
<a href="{{ url('profile_delete?id=' . $user->id) }}"
|
||||
class="px-4 py-2 bg-red-600 dark:bg-red-700 text-white text-sm rounded hover:bg-red-700 dark:hover:bg-red-800 transition"
|
||||
|
||||
@@ -34,6 +34,7 @@ Schedule::command('nntmux:disable-expired-registration-periods')->everyFiveMinut
|
||||
Schedule::command('nntmux:remove-bad')->hourly()->withoutOverlapping();
|
||||
Schedule::command('cloudflare:reload')->daily()->withoutOverlapping();
|
||||
Schedule::command('cache:prune-stale-tags')->hourly();
|
||||
Schedule::command('gdpr:purge-expired-exports')->dailyAt('03:30')->withoutOverlapping();
|
||||
Schedule::command('nntmux:collect-stats')->everyFifteenMinutes()->withoutOverlapping();
|
||||
Schedule::command('nntmux:populate-steam-apps')->monthly();
|
||||
// Collect system metrics every 5 minutes
|
||||
|
||||
@@ -44,6 +44,7 @@ use App\Http\Controllers\Admin\AdminTmuxController;
|
||||
use App\Http\Controllers\Admin\AdminUserController;
|
||||
use App\Http\Controllers\Admin\AdminUserRoleHistoryController;
|
||||
use App\Http\Controllers\Admin\DeletedUsersController;
|
||||
use App\Http\Controllers\Admin\GdprRequestController;
|
||||
use App\Http\Controllers\AdultController;
|
||||
use App\Http\Controllers\AjaxController;
|
||||
use App\Http\Controllers\Api\FileListApiController;
|
||||
@@ -75,6 +76,7 @@ use App\Http\Controllers\MyMoviesController;
|
||||
use App\Http\Controllers\MyShowsController;
|
||||
use App\Http\Controllers\NfoController;
|
||||
use App\Http\Controllers\PasswordSecurityController;
|
||||
use App\Http\Controllers\PrivacyCenterController;
|
||||
use App\Http\Controllers\PrivacyPolicyController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ProfileSecurityController;
|
||||
@@ -198,6 +200,10 @@ Route::middleware(['auth', 'isVerified'])->group(function () {
|
||||
Route::match(['GET', 'POST'], 'profileedit', [ProfileController::class, 'edit'])->name('profileedit');
|
||||
Route::match(['GET', 'POST'], 'profile_delete', [ProfileController::class, 'destroy'])->name('profile_delete');
|
||||
Route::post('profile/update-theme', [ProfileController::class, 'updateTheme'])->name('profile.update-theme');
|
||||
Route::get('privacy-center', [PrivacyCenterController::class, 'index'])->name('privacy-center.index');
|
||||
Route::post('privacy-center/export', [PrivacyCenterController::class, 'requestExport'])->name('privacy-center.export');
|
||||
Route::post('privacy-center/erasure', [PrivacyCenterController::class, 'requestErasure'])->name('privacy-center.erasure');
|
||||
Route::get('privacy-center/export/{gdprRequest}/download', [PrivacyCenterController::class, 'downloadExport'])->name('privacy-center.export.download');
|
||||
Route::match(['GET', 'POST'], 'search', [SearchController::class, 'search'])->name('search');
|
||||
|
||||
// Release Report routes
|
||||
@@ -347,6 +353,13 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () {
|
||||
Route::post('deleted-users/bulk', [DeletedUsersController::class, 'bulkAction'])->name('admin.deleted.users.bulk');
|
||||
Route::post('deleted-users/restore/{id}', [DeletedUsersController::class, 'restore'])->name('admin.deleted.users.restore');
|
||||
Route::post('deleted-users/permanent-delete/{id}', [DeletedUsersController::class, 'permanentDelete'])->name('admin.deleted.users.permanent-delete');
|
||||
|
||||
// GDPR / privacy request management
|
||||
Route::get('gdpr-requests', [GdprRequestController::class, 'index'])->name('admin.gdpr-requests.index');
|
||||
Route::get('gdpr-requests/{gdprRequest}', [GdprRequestController::class, 'show'])->name('admin.gdpr-requests.show');
|
||||
Route::post('gdpr-requests/{gdprRequest}/generate-export', [GdprRequestController::class, 'generateExport'])->name('admin.gdpr-requests.generate-export');
|
||||
Route::post('gdpr-requests/{gdprRequest}/complete-erasure', [GdprRequestController::class, 'completeErasure'])->name('admin.gdpr-requests.complete-erasure');
|
||||
Route::post('gdpr-requests/{gdprRequest}/reject', [GdprRequestController::class, 'reject'])->name('admin.gdpr-requests.reject');
|
||||
});
|
||||
|
||||
Route::middleware('role_or_permission:Admin|Moderator|edit release')->prefix('admin')->group(function () {
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Mail\GdprRequestStatusMail;
|
||||
use App\Models\GdprRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Gdpr\GdprErasureService;
|
||||
use App\Services\Gdpr\GdprExportService;
|
||||
use App\Services\Gdpr\GdprNotificationService;
|
||||
use App\Services\Gdpr\GdprRetentionService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
class GdprComplianceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
'app.key' => 'base64:'.base64_encode(random_bytes(32)),
|
||||
'session.driver' => 'array',
|
||||
'cache.default' => 'array',
|
||||
'mail.from.address' => 'admin@example.test',
|
||||
'nntmux_settings.store_user_ip' => false,
|
||||
]);
|
||||
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
|
||||
$this->createSchema();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function test_export_includes_retained_payment_and_audit_records(): void
|
||||
{
|
||||
Mail::fake();
|
||||
Storage::fake('local');
|
||||
$user = $this->createUser();
|
||||
|
||||
DB::table('payments')->insert($this->paymentRow($user->email, $user->username, 'payment-1'));
|
||||
DB::table('user_activities')->insert($this->activityRow($user->id, $user->username, ['email' => $user->email]));
|
||||
|
||||
$gdprRequest = GdprRequest::create([
|
||||
'user_id' => $user->id,
|
||||
'requester_username' => $user->username,
|
||||
'requester_email' => $user->email,
|
||||
'type' => GdprRequest::TYPE_EXPORT,
|
||||
'status' => GdprRequest::STATUS_PROCESSING,
|
||||
]);
|
||||
|
||||
$result = app(GdprExportService::class)->generate($user, $gdprRequest);
|
||||
|
||||
Storage::disk('local')->assertExists($result['path']);
|
||||
$payload = json_decode(Storage::disk('local')->get($result['path']), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->assertSame($user->email, $payload['subject']['email']);
|
||||
$this->assertArrayNotHasKey('password', $payload['account']);
|
||||
$this->assertArrayNotHasKey('api_token', $payload['account']);
|
||||
$this->assertCount(1, $payload['retained_payment_records']);
|
||||
$this->assertSame('payment-1', $payload['retained_payment_records'][0]['payment_id']);
|
||||
$this->assertNotEmpty($payload['retained_audit_records']);
|
||||
$this->assertSame(GdprRequest::STATUS_COMPLETED, $gdprRequest->fresh()->status);
|
||||
Mail::assertSent(GdprRequestStatusMail::class, fn (GdprRequestStatusMail $mail): bool => $mail->hasTo($user->email) && $mail->heading === 'Your data export is ready');
|
||||
}
|
||||
|
||||
public function test_erasure_anonymizes_account_removes_usage_and_retains_payment_data(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$originalEmail = $user->email;
|
||||
$originalUsername = $user->username;
|
||||
|
||||
DB::table('payments')->insert($this->paymentRow($originalEmail, $originalUsername, 'payment-2'));
|
||||
DB::table('user_requests')->insert(['users_id' => $user->id, 'request' => 'search=linux', 'hosthash' => 'abc', 'timestamp' => now()]);
|
||||
DB::table('user_downloads')->insert(['users_id' => $user->id, 'releases_id' => 123, 'hosthash' => 'def', 'timestamp' => now()]);
|
||||
DB::table('release_comments')->insert([
|
||||
'users_id' => $user->id,
|
||||
'releases_id' => 1,
|
||||
'text' => 'hello',
|
||||
'username' => $originalUsername,
|
||||
'host' => '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('user_activities')->insert($this->activityRow($user->id, $originalUsername, ['email' => $originalEmail, 'ip_address' => '127.0.0.1']));
|
||||
|
||||
app(GdprErasureService::class)->eraseForAccountDeletion($user, $user);
|
||||
|
||||
$deletedUser = User::withTrashed()->findOrFail($user->id);
|
||||
$this->assertSoftDeleted('users', ['id' => $user->id]);
|
||||
$this->assertSame('deleted_user_'.$user->id, $deletedUser->username);
|
||||
$this->assertSame('deleted-user-'.$user->id.'@deleted.invalid', $deletedUser->email);
|
||||
$this->assertDatabaseMissing('user_requests', ['users_id' => $user->id]);
|
||||
$this->assertDatabaseMissing('user_downloads', ['users_id' => $user->id]);
|
||||
$this->assertDatabaseMissing('payments', ['email' => $originalEmail, 'payment_id' => 'payment-2']);
|
||||
$this->assertDatabaseMissing('payments', ['username' => $originalUsername, 'payment_id' => 'payment-2']);
|
||||
$this->assertDatabaseHas('payments', [
|
||||
'email' => 'deleted-user-'.$user->id.'@deleted.invalid',
|
||||
'username' => 'deleted_user_'.$user->id,
|
||||
'payment_id' => 'payment-2',
|
||||
]);
|
||||
$this->assertDatabaseHas('release_comments', ['users_id' => $user->id, 'username' => 'deleted_user_'.$user->id, 'host' => null]);
|
||||
|
||||
$activity = DB::table('user_activities')->where('activity_type', 'registered')->first();
|
||||
$metadata = json_decode((string) $activity->metadata, true, flags: JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('deleted_user_'.$user->id, $activity->username);
|
||||
$this->assertArrayNotHasKey('email', $metadata);
|
||||
$this->assertArrayNotHasKey('ip_address', $metadata);
|
||||
$this->assertTrue($metadata['gdpr_minimized']);
|
||||
}
|
||||
|
||||
public function test_erasure_request_is_completed_and_audited(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$user = $this->createUser();
|
||||
$gdprRequest = GdprRequest::create([
|
||||
'user_id' => $user->id,
|
||||
'requester_username' => $user->username,
|
||||
'requester_email' => $user->email,
|
||||
'type' => GdprRequest::TYPE_ERASURE,
|
||||
'status' => GdprRequest::STATUS_PROCESSING,
|
||||
]);
|
||||
|
||||
app(GdprErasureService::class)->eraseForAccountDeletion($user, $user, $gdprRequest);
|
||||
|
||||
$freshRequest = $gdprRequest->fresh();
|
||||
$this->assertSame(GdprRequest::STATUS_COMPLETED, $freshRequest->status);
|
||||
$this->assertSame('completed', $freshRequest->response_payload['erasure']);
|
||||
$this->assertSame('payments', $freshRequest->response_payload['retained_records'][0]['table']);
|
||||
$this->assertSame('retained_anonymized', $freshRequest->response_payload['retained_records'][0]['erasure_action']);
|
||||
$this->assertDatabaseHas('gdpr_audit_logs', [
|
||||
'gdpr_request_id' => $gdprRequest->id,
|
||||
'user_id' => $user->id,
|
||||
'event' => 'erasure_soft_delete',
|
||||
]);
|
||||
Mail::assertSent(GdprRequestStatusMail::class, fn (GdprRequestStatusMail $mail): bool => $mail->hasTo($user->email) && $mail->heading === 'Your account erasure is complete');
|
||||
}
|
||||
|
||||
public function test_erasure_submission_notifies_requester_and_admin(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$user = $this->createUser();
|
||||
$gdprRequest = GdprRequest::create([
|
||||
'user_id' => $user->id,
|
||||
'requester_username' => $user->username,
|
||||
'requester_email' => $user->email,
|
||||
'type' => GdprRequest::TYPE_ERASURE,
|
||||
'status' => GdprRequest::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
app(GdprNotificationService::class)->requestSubmitted($gdprRequest);
|
||||
|
||||
Mail::assertSent(GdprRequestStatusMail::class, fn (GdprRequestStatusMail $mail): bool => $mail->hasTo($user->email) && $mail->heading === 'We received your GDPR request');
|
||||
Mail::assertSent(GdprRequestStatusMail::class, fn (GdprRequestStatusMail $mail): bool => $mail->hasTo('admin@example.test') && $mail->heading === 'New GDPR request submitted');
|
||||
}
|
||||
|
||||
public function test_expired_exports_are_purged_but_request_record_is_retained(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$user = $this->createUser();
|
||||
$path = 'gdpr/exports/expired-export.json';
|
||||
Storage::disk('local')->put($path, '{"expired":true}');
|
||||
|
||||
$gdprRequest = GdprRequest::create([
|
||||
'user_id' => $user->id,
|
||||
'requester_username' => $user->username,
|
||||
'requester_email' => $user->email,
|
||||
'type' => GdprRequest::TYPE_EXPORT,
|
||||
'status' => GdprRequest::STATUS_COMPLETED,
|
||||
'export_disk' => 'local',
|
||||
'export_path' => $path,
|
||||
'export_expires_at' => now()->subMinute(),
|
||||
'completed_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$purged = app(GdprRetentionService::class)->purgeExpiredExports();
|
||||
|
||||
$this->assertSame(1, $purged);
|
||||
Storage::disk('local')->assertMissing($path);
|
||||
$this->assertDatabaseHas('gdpr_requests', [
|
||||
'id' => $gdprRequest->id,
|
||||
'status' => GdprRequest::STATUS_COMPLETED,
|
||||
'export_path' => null,
|
||||
'export_disk' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function paymentRow(string $email, string $username, string $paymentId): array
|
||||
{
|
||||
return [
|
||||
'email' => $email,
|
||||
'username' => $username,
|
||||
'item_description' => 'VIP role',
|
||||
'order_id' => 'order-'.$paymentId,
|
||||
'payment_id' => $paymentId,
|
||||
'payment_status' => 'Settled',
|
||||
'invoice_status' => 'Settled',
|
||||
'invoice_amount' => '10.00',
|
||||
'payment_method' => 'BTC',
|
||||
'payment_value' => '10.00',
|
||||
'webhook_id' => 'webhook-'.$paymentId,
|
||||
'invoice_id' => 'invoice-'.$paymentId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function activityRow(int $userId, string $username, array $metadata): array
|
||||
{
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'username' => $username,
|
||||
'activity_type' => 'registered',
|
||||
'description' => 'Registration audit',
|
||||
'metadata' => json_encode($metadata, JSON_UNESCAPED_SLASHES),
|
||||
'is_permanent' => false,
|
||||
'created_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->string('name')->primary();
|
||||
$table->text('value')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('roles', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->boolean('isdefault')->default(false);
|
||||
$table->unsignedInteger('defaultinvites')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('permissions', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('username')->unique();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('firstname')->nullable();
|
||||
$table->string('lastname')->nullable();
|
||||
$table->string('email')->unique();
|
||||
$table->string('password');
|
||||
$table->string('host')->default('');
|
||||
$table->unsignedInteger('roles_id')->default(1);
|
||||
$table->unsignedInteger('invites')->default(0);
|
||||
$table->text('notes')->nullable();
|
||||
$table->integer('rate_limit')->default(60);
|
||||
$table->string('api_token')->nullable()->unique();
|
||||
$table->string('resetguid')->nullable();
|
||||
$table->boolean('verified')->default(true);
|
||||
$table->boolean('can_post')->default(true);
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('verification_token')->nullable();
|
||||
$table->string('remember_token')->nullable();
|
||||
$table->string('session_token')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
$table->string('deleted_by')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('model_has_roles', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['role_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('model_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->string('model_type');
|
||||
$table->unsignedInteger('model_id');
|
||||
$table->primary(['permission_id', 'model_id', 'model_type']);
|
||||
});
|
||||
|
||||
Schema::create('role_has_permissions', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('permission_id');
|
||||
$table->unsignedInteger('role_id');
|
||||
$table->primary(['permission_id', 'role_id']);
|
||||
});
|
||||
|
||||
Schema::create('user_activities', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->string('username');
|
||||
$table->string('activity_type', 50);
|
||||
$table->text('description');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->boolean('is_permanent')->default(false);
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('payments', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->timestamps();
|
||||
$table->string('email');
|
||||
$table->string('username');
|
||||
$table->string('item_description');
|
||||
$table->string('order_id');
|
||||
$table->string('payment_id');
|
||||
$table->string('payment_status');
|
||||
$table->string('invoice_status')->nullable();
|
||||
$table->string('invoice_amount');
|
||||
$table->string('payment_method');
|
||||
$table->string('payment_value');
|
||||
$table->string('webhook_id');
|
||||
$table->string('invoice_id');
|
||||
});
|
||||
|
||||
Schema::create('user_requests', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('users_id');
|
||||
$table->string('request')->default('');
|
||||
$table->string('hosthash')->default('');
|
||||
$table->timestamp('timestamp')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('user_downloads', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('users_id');
|
||||
$table->unsignedInteger('releases_id')->default(0);
|
||||
$table->string('hosthash')->default('');
|
||||
$table->timestamp('timestamp')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('release_comments', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('users_id');
|
||||
$table->unsignedInteger('releases_id');
|
||||
$table->text('text');
|
||||
$table->string('username');
|
||||
$table->string('host')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('gdpr_requests', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('requester_username')->nullable();
|
||||
$table->string('requester_email')->nullable();
|
||||
$table->string('type', 32);
|
||||
$table->string('status', 32)->default('pending');
|
||||
$table->json('request_payload')->nullable();
|
||||
$table->json('response_payload')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->text('admin_notes')->nullable();
|
||||
$table->string('export_disk')->nullable();
|
||||
$table->string('export_path')->nullable();
|
||||
$table->timestamp('export_expires_at')->nullable();
|
||||
$table->unsignedBigInteger('processed_by')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('gdpr_consents', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('consent_type');
|
||||
$table->string('status')->default('granted');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('gdpr_audit_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('gdpr_request_id')->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->unsignedBigInteger('actor_id')->nullable();
|
||||
$table->string('event', 64);
|
||||
$table->text('description');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
DB::table('settings')->insert([
|
||||
['name' => 'title', 'value' => 'NNTmux Test'],
|
||||
['name' => 'home_link', 'value' => '/'],
|
||||
['name' => 'categorizeforeign', 'value' => '0'],
|
||||
['name' => 'catwebdl', 'value' => '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function createUser(): User
|
||||
{
|
||||
$role = Role::query()->firstOrCreate(
|
||||
['name' => 'User', 'guard_name' => 'web'],
|
||||
['rate_limit' => 60, 'isdefault' => true, 'defaultinvites' => 1]
|
||||
);
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => 'user_'.Str::random(8),
|
||||
'email' => Str::random(12).'@example.test',
|
||||
'password' => bcrypt('password'),
|
||||
'roles_id' => $role->id,
|
||||
'rate_limit' => 60,
|
||||
'api_token' => Str::random(32),
|
||||
'verified' => true,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user