mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
241 lines
7.5 KiB
PHP
241 lines
7.5 KiB
PHP
<?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);
|
|
}
|
|
}
|