From ab41b91af5138c17d8cbf5f8846dbb347e3b9902 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Wed, 17 Jun 2026 09:49:39 +0200 Subject: [PATCH] Add GDPR compliance --- .../Admin/DeletedUsersController.php | 37 +- .../Admin/GdprRequestController.php | 209 +++++++++ .../Controllers/PrivacyCenterController.php | 155 +++++++ app/Http/Controllers/ProfileController.php | 22 +- app/Mail/GdprRequestStatusMail.php | 152 ++++++ app/Models/GdprAuditLog.php | 60 +++ app/Models/GdprRequest.php | 110 +++++ app/Observers/UserActivityObserver.php | 6 +- app/Services/Gdpr/GdprDataInventory.php | 136 ++++++ app/Services/Gdpr/GdprErasureService.php | 308 +++++++++++++ app/Services/Gdpr/GdprExportService.php | 240 ++++++++++ app/Services/Gdpr/GdprNotificationService.php | 56 +++ app/Services/Gdpr/GdprRetentionService.php | 56 +++ resources/views/admin/gdpr/show.blade.php | 148 ++++++ resources/views/admin/users/deleted.blade.php | 12 + .../markdown/gdprRequestStatus.blade.php | 32 ++ resources/views/partials/admin-menu.blade.php | 3 + .../views/privacy-center/index.blade.php | 129 ++++++ resources/views/privacy-policy.blade.php | 18 +- resources/views/profile/index.blade.php | 5 + routes/console.php | 1 + routes/web.php | 13 + tests/Feature/GdprComplianceTest.php | 431 ++++++++++++++++++ 23 files changed, 2321 insertions(+), 18 deletions(-) create mode 100644 app/Http/Controllers/Admin/GdprRequestController.php create mode 100644 app/Http/Controllers/PrivacyCenterController.php create mode 100644 app/Mail/GdprRequestStatusMail.php create mode 100644 app/Models/GdprAuditLog.php create mode 100644 app/Models/GdprRequest.php create mode 100644 app/Services/Gdpr/GdprDataInventory.php create mode 100644 app/Services/Gdpr/GdprErasureService.php create mode 100644 app/Services/Gdpr/GdprExportService.php create mode 100644 app/Services/Gdpr/GdprNotificationService.php create mode 100644 app/Services/Gdpr/GdprRetentionService.php create mode 100644 resources/views/admin/gdpr/show.blade.php create mode 100644 resources/views/emails/markdown/gdprRequestStatus.blade.php create mode 100644 resources/views/privacy-center/index.blade.php create mode 100644 tests/Feature/GdprComplianceTest.php diff --git a/app/Http/Controllers/Admin/DeletedUsersController.php b/app/Http/Controllers/Admin/DeletedUsersController.php index c47d61f5a..618a9f216 100644 --- a/app/Http/Controllers/Admin/DeletedUsersController.php +++ b/app/Http/Controllers/Admin/DeletedUsersController.php @@ -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.'); diff --git a/app/Http/Controllers/Admin/GdprRequestController.php b/app/Http/Controllers/Admin/GdprRequestController.php new file mode 100644 index 000000000..77ac997ea --- /dev/null +++ b/app/Http/Controllers/Admin/GdprRequestController.php @@ -0,0 +1,209 @@ + + */ + private const TYPES = [ + GdprRequest::TYPE_EXPORT, + GdprRequest::TYPE_ERASURE, + GdprRequest::TYPE_RECTIFICATION, + GdprRequest::TYPE_RESTRICTION, + ]; + + /** + * @var list + */ + 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); + } +} diff --git a/app/Http/Controllers/PrivacyCenterController.php b/app/Http/Controllers/PrivacyCenterController.php new file mode 100644 index 000000000..5c7896f54 --- /dev/null +++ b/app/Http/Controllers/PrivacyCenterController.php @@ -0,0 +1,155 @@ +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'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index bcd203c80..fc2950d50 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -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'); } diff --git a/app/Mail/GdprRequestStatusMail.php b/app/Mail/GdprRequestStatusMail.php new file mode 100644 index 000000000..2fa9585db --- /dev/null +++ b/app/Mail/GdprRequestStatusMail.php @@ -0,0 +1,152 @@ +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 $parameters + */ + private function safeRoute(string $name, array $parameters = []): ?string + { + try { + return route($name, $parameters); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Models/GdprAuditLog.php b/app/Models/GdprAuditLog.php new file mode 100644 index 000000000..18dece18f --- /dev/null +++ b/app/Models/GdprAuditLog.php @@ -0,0 +1,60 @@ + + */ + protected $fillable = [ + 'gdpr_request_id', + 'user_id', + 'actor_id', + 'event', + 'description', + 'metadata', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'metadata' => 'array', + 'created_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function gdprRequest(): BelongsTo + { + return $this->belongsTo(GdprRequest::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class)->withTrashed(); + } + + /** + * @return BelongsTo + */ + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id')->withTrashed(); + } +} diff --git a/app/Models/GdprRequest.php b/app/Models/GdprRequest.php new file mode 100644 index 000000000..56cd27d59 --- /dev/null +++ b/app/Models/GdprRequest.php @@ -0,0 +1,110 @@ + + */ + 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 + */ + 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 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class)->withTrashed(); + } + + /** + * @return BelongsTo + */ + public function processor(): BelongsTo + { + return $this->belongsTo(User::class, 'processed_by')->withTrashed(); + } + + /** + * @return HasMany + */ + public function auditLogs(): HasMany + { + return $this->hasMany(GdprAuditLog::class)->latest(); + } + + /** + * @param Builder $query + * @return Builder + */ + 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()); + } +} diff --git a/app/Observers/UserActivityObserver.php b/app/Observers/UserActivityObserver.php index 9d1f3cfb6..1ecdb8067 100644 --- a/app/Observers/UserActivityObserver.php +++ b/app/Observers/UserActivityObserver.php @@ -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), ]); } diff --git a/app/Services/Gdpr/GdprDataInventory.php b/app/Services/Gdpr/GdprDataInventory.php new file mode 100644 index 000000000..98aa65a98 --- /dev/null +++ b/app/Services/Gdpr/GdprDataInventory.php @@ -0,0 +1,136 @@ +> + */ + 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> + */ + 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> + */ + 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> + */ + 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> + */ + 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'], + ]; + } +} diff --git a/app/Services/Gdpr/GdprErasureService.php b/app/Services/Gdpr/GdprErasureService.php new file mode 100644 index 000000000..35ef96ddc --- /dev/null +++ b/app/Services/Gdpr/GdprErasureService.php @@ -0,0 +1,308 @@ +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 + */ + 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 $updates + * @return array + */ + 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 + ); + } +} diff --git a/app/Services/Gdpr/GdprExportService.php b/app/Services/Gdpr/GdprExportService.php new file mode 100644 index 000000000..341bd971b --- /dev/null +++ b/app/Services/Gdpr/GdprExportService.php @@ -0,0 +1,240 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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> + */ + 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> + */ + 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 $redactedColumns + * @return array> + */ + 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 $redactedColumns + * @return array + */ + 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 + */ + private function modelToArray(object $model): array + { + return json_decode(json_encode($model, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/app/Services/Gdpr/GdprNotificationService.php b/app/Services/Gdpr/GdprNotificationService.php new file mode 100644 index 000000000..eca542dc2 --- /dev/null +++ b/app/Services/Gdpr/GdprNotificationService.php @@ -0,0 +1,56 @@ +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)); + } +} diff --git a/app/Services/Gdpr/GdprRetentionService.php b/app/Services/Gdpr/GdprRetentionService.php new file mode 100644 index 000000000..1c35d1a2b --- /dev/null +++ b/app/Services/Gdpr/GdprRetentionService.php @@ -0,0 +1,56 @@ + + */ + 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; + } +} diff --git a/resources/views/admin/gdpr/show.blade.php b/resources/views/admin/gdpr/show.blade.php new file mode 100644 index 000000000..3cd47a657 --- /dev/null +++ b/resources/views/admin/gdpr/show.blade.php @@ -0,0 +1,148 @@ +@extends('layouts.admin') + +@section('content') +
+
+
+
+

+ {{ $title }} +

+ + Back + +
+
+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + @if($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+
+
+

Request Details

+
+
+
Requester
+
{{ $gdprRequest->requester_username ?? 'N/A' }} <{{ $gdprRequest->requester_email ?? 'N/A' }}>
+
+
+
Subject User
+
+ @if($subject) + {{ $subject->username }} (#{{ $subject->id }}) @if($subject->trashed()) deleted @endif + @else + Missing/deleted + @endif +
+
+
+
Type
+
{{ ucfirst($gdprRequest->type) }}
+
+
+
Status
+
{{ ucfirst($gdprRequest->status) }}
+
+
+
Created
+
{{ $gdprRequest->created_at?->format('Y-m-d H:i') }}
+
+
+
Completed
+
{{ $gdprRequest->completed_at?->format('Y-m-d H:i') ?? '—' }}
+
+
+ + @if($gdprRequest->notes) +
+

User Notes

+

{{ $gdprRequest->notes }}

+
+ @endif + + @if($gdprRequest->admin_notes) +
+

Admin Notes

+

{{ $gdprRequest->admin_notes }}

+
+ @endif +
+ +
+

Audit Trail

+
+ @forelse($gdprRequest->auditLogs as $auditLog) +
+
{{ $auditLog->event }}
+
{{ $auditLog->description }}
+
{{ $auditLog->created_at?->format('Y-m-d H:i:s') }}
+
+ @empty +

No GDPR audit events recorded for this request yet.

+ @endforelse +
+
+
+ +
+
+

Actions

+ + @if($gdprRequest->type === \App\Models\GdprRequest::TYPE_EXPORT && in_array($gdprRequest->status, [\App\Models\GdprRequest::STATUS_PENDING, \App\Models\GdprRequest::STATUS_PROCESSING], true)) +
+ @csrf + +
+ @endif + + @if($gdprRequest->type === \App\Models\GdprRequest::TYPE_ERASURE && in_array($gdprRequest->status, [\App\Models\GdprRequest::STATUS_PENDING, \App\Models\GdprRequest::STATUS_PROCESSING], true)) +
+ @csrf + +
+ @endif + + @if(in_array($gdprRequest->status, [\App\Models\GdprRequest::STATUS_PENDING, \App\Models\GdprRequest::STATUS_PROCESSING], true)) +
+ @csrf + + + +
+ @else +

No actions available for completed, cancelled, or rejected requests.

+ @endif +
+ + @if($gdprRequest->isDownloadableExport()) +
+

Export Ready

+

The user can download this export until {{ $gdprRequest->export_expires_at?->format('Y-m-d H:i') }}.

+
+ @endif +
+
+
+
+@endsection + diff --git a/resources/views/admin/users/deleted.blade.php b/resources/views/admin/users/deleted.blade.php index 580702fba..7b96af418 100644 --- a/resources/views/admin/users/deleted.blade.php +++ b/resources/views/admin/users/deleted.blade.php @@ -161,6 +161,7 @@ Deleted By + GDPR Erasure Actions @@ -214,6 +215,17 @@ N/A @endif + + @if($user->gdpr_erasure_request_id) + + GDPR + + @else + + No + + @endif +
diff --git a/resources/views/privacy-center/index.blade.php b/resources/views/privacy-center/index.blade.php new file mode 100644 index 000000000..3bb7de1b7 --- /dev/null +++ b/resources/views/privacy-center/index.blade.php @@ -0,0 +1,129 @@ +@extends('layouts.main') + +@section('content') +
+
+
+

+ Privacy Center +

+

Export your account data and manage GDPR requests.

+
+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + @if(session('error')) +
+ {{ session('error') }} +
+ @endif + + @if($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+
+

+ Download Your Data +

+

+ Generate a JSON export containing your account profile, usage records, comments, requests, GDPR history, and retained payment/audit records that relate to your account. +

+
+ @csrf + + + +
+
+ +
+

+ Request Account Erasure +

+

+ 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. +

+
+ @csrf + + + + + +
+
+
+
+ +
+
+

Your GDPR Requests

+
+
+ + + + + + + + + + + @forelse($gdprRequests as $gdprRequest) + + + + + + + @empty + + + + @endforelse + +
DateTypeStatusResult
{{ $gdprRequest->created_at?->format('Y-m-d H:i') }}{{ ucfirst($gdprRequest->type) }} + {{ ucfirst($gdprRequest->status) }} + + @if($gdprRequest->isDownloadableExport()) + + Download export + + Expires {{ $gdprRequest->export_expires_at?->diffForHumans() }} + @elseif($gdprRequest->admin_notes) + {{ $gdprRequest->admin_notes }} + @else + — + @endif +
No GDPR requests yet.
+
+
+ {{ $gdprRequests->links() }} +
+
+ +
+

Essential Cookies & Retention

+

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.

+
    + @foreach($retentionPolicy['retained_records'] as $record) +
  • {{ $record['table'] }}: {{ $record['reason'] }} ({{ str_replace('_', ' ', $record['erasure_action']) }})
  • + @endforeach +
+
+
+@endsection + diff --git a/resources/views/privacy-policy.blade.php b/resources/views/privacy-policy.blade.php index c150d907b..c21aaf44e 100644 --- a/resources/views/privacy-policy.blade.php +++ b/resources/views/privacy-policy.blade.php @@ -34,7 +34,9 @@

When you use {{ config('app.name') }}, we may collect the following types of information:

  • Account Information: Username, email address, and password when you register.
  • -
  • Cookies: We use cookies to maintain your session and preferences.
  • +
  • Usage Information: API requests, downloads, comments, invitations, security events, and account activity needed to operate and secure the service.
  • +
  • Payment Information: Payment and invoice records when you make a payment or donation.
  • +
  • Essential Cookies and Storage: 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.

2. How We Use Your Information

@@ -71,27 +73,31 @@
  • Resolve disputes
  • When you delete your account, we will delete or anonymize your personal information, unless we are required to retain it for legal purposes.

    +

    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.

    6. Your Rights and Choices

    You have the following rights regarding your personal information:

    • Access: Request access to the personal information we hold about you
    • +
    • Portability: Request an export of your account data in a machine-readable format
    • Correction: Request correction of inaccurate or incomplete information
    • Deletion: Request deletion of your account and associated data
    • +
    • Restriction or Objection: Ask us to restrict or review certain processing where applicable
    -

    To exercise these rights, please contact us through your account settings or the contact methods provided on our website.

    +

    To exercise these rights, use the Privacy Center in your account or contact us through the contact methods provided on our website.

    7. Cookies and Tracking Technologies

    -

    We use cookies and similar tracking technologies to:

    +

    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:

    • Maintain your login session
    • -
    • Remember your preferences (such as dark mode settings)
    • -
    • Analyze site usage and performance
    • +
    • Protect forms and authenticated requests from cross-site request forgery
    • +
    • Support security features such as optional two-factor trusted devices
    • +
    • Remember display preferences such as theme and color scheme

    You can control cookie settings through your browser preferences. Note that disabling cookies may affect the functionality of our service.

    8. Third-Party Services

    -

    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.

    +

    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.

    9. Children's Privacy

    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.

    diff --git a/resources/views/profile/index.blade.php b/resources/views/profile/index.blade.php index db930ec06..1d2ccccd3 100644 --- a/resources/views/profile/index.blade.php +++ b/resources/views/profile/index.blade.php @@ -24,6 +24,11 @@ Edit Profile @endif + @if(!($isadmin ?? false) && !$publicview) + + Privacy Center + + @endif @if(!($isadmin ?? false) && !$publicview) 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 diff --git a/routes/web.php b/routes/web.php index 5af73c596..a1fc05778 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 () { diff --git a/tests/Feature/GdprComplianceTest.php b/tests/Feature/GdprComplianceTest.php new file mode 100644 index 000000000..7b2e369bf --- /dev/null +++ b/tests/Feature/GdprComplianceTest.php @@ -0,0 +1,431 @@ + '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 + */ + 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 $metadata + * @return array + */ + 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(); + } +}