Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add GDPR-Export for user #2444

Open
wants to merge 22 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use App\Console\Commands\WikidataFetcher;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Spatie\PersonalDataExport\Commands\CleanOldPersonalDataExportsCommand;

class Kernel extends ConsoleKernel
{
Expand Down Expand Up @@ -44,6 +45,7 @@ protected function schedule(Schedule $schedule): void {
//daily tasks
$schedule->command(DatabaseCleaner::class)->daily();
$schedule->command(CleanUpProfilePictures::class)->daily();
$schedule->command(CleanOldPersonalDataExportsCommand::class)->daily();

//weekly tasks
$schedule->command(MastodonServers::class)->weekly();
Expand Down
36 changes: 34 additions & 2 deletions app/Http/Controllers/API/v1/ExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Enum\ExportableColumn;
use App\Exceptions\DataOverflowException;
use App\Http\Controllers\Backend\Export\ExportController as ExportBackend;
use App\Jobs\MonitoredPersonalDataExportJob;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
Expand All @@ -15,18 +16,37 @@

class ExportController extends Controller
{
public function requestGdprExport(Request $request): JsonResponse|Response|RedirectResponse {
$validated = $request->validate([
'frontend' => ['nullable', 'boolean'],
]);

$user = $request->user();

if ($user->recent_gdpr_export && $user->recent_gdpr_export->diffInDays(now()) < 30) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Könnte man ggfs. mit dem Laravel Ratelimiter lösen.

return $this->frontendOrJson($validated, ['error' => __('export.error.gdpr-time', ['date' => userTime($user->recent_gdpr_export)])]);
}

$user->update(['recent_gdpr_export' => now()]);

dispatch(new MonitoredPersonalDataExportJob($user));

return $this->frontendOrJson($validated, ['message' => __('export.requested')], 200);
}

public function generateStatusExport(Request $request): JsonResponse|StreamedResponse|Response|RedirectResponse {
$validated = $request->validate([
'from' => ['required', 'date', 'before_or_equal:until'],
'until' => ['required', 'date', 'after_or_equal:from'],
'columns.*' => ['required', Rule::enum(ExportableColumn::class)],
'filetype' => ['required', Rule::in(['pdf', 'csv_human', 'csv_machine', 'json'])],
'frontend' => ['nullable', 'boolean'],
]);

$from = Carbon::parse($validated['from']);
$until = Carbon::parse($validated['until']);
if ($from->diffInDays($until) > 365) {
return back()->with('error', __('export.error.time'));
return $this->frontendOrJson($validated, ['error' => __('export.error.time')]);
}

if ($validated['filetype'] === 'json') {
Expand All @@ -49,7 +69,19 @@ public function generateStatusExport(Request $request): JsonResponse|StreamedRes
filetype: $validated['filetype']
);
} catch (DataOverflowException) {
return back()->with('error', __('export.error.amount'));
return $this->frontendOrJson($validated, ['error' => __('export.error.amount')], 406);
}
}

private function frontendOrJson(array $validated, array $data, int $status = 400): RedirectResponse|JsonResponse {
if (empty($validated['frontend'])) {
return response()->json($data, $status);
}

if (array_key_exists('error', $data)) {
return redirect('export')->with($data);
}

return redirect('export')->with('success', $data['message']);
}
}
20 changes: 20 additions & 0 deletions app/Jobs/MonitoredPersonalDataExportJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Jobs;

use romanzipp\QueueMonitor\Traits\IsMonitored;
use Spatie\PersonalDataExport\ExportsPersonalData;
use Spatie\PersonalDataExport\Jobs\CreatePersonalDataExportJob;

class MonitoredPersonalDataExportJob extends CreatePersonalDataExportJob
{

use IsMonitored;

public $timeout = 30 * 60;


protected function ensureValidUser(ExportsPersonalData $user) {
// Do nothing since we are not enforcing the user to have an email property
}
}
1 change: 1 addition & 0 deletions app/Models/MastodonServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class MastodonServer extends Model
protected $casts = [
'id' => 'integer',
];
protected $hidden = ['client_id', 'client_secret'];

public function socialProfiles(): HasMany {
return $this->hasMany(SocialLoginProfile::class, 'mastodon_server', 'id');
Expand Down
4 changes: 4 additions & 0 deletions app/Models/OAuthClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class OAuthClient extends PassportClient {
'revoked' => 'bool',
];

protected $hidden = [
'secret',
];
MrKrisKrisu marked this conversation as resolved.
Show resolved Hide resolved

public static function newFactory() {
return parent::newFactory();
}
Expand Down
3 changes: 3 additions & 0 deletions app/Models/Status.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ public function getFavoritedAttribute(): ?bool {
}

public function getDescriptionAttribute(): string {
if ($this->checkin === null) {
MrKrisKrisu marked this conversation as resolved.
Show resolved Hide resolved
return $this->body ?? '';
}
return __('description.status', [
'username' => $this->user->name,
'origin' => $this->checkin->originStopover->station->name .
Expand Down
23 changes: 21 additions & 2 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Exceptions\RateLimitExceededException;
use App\Http\Controllers\Backend\Social\MastodonProfileDetails;
use App\Jobs\SendVerificationEmail;
use App\Services\PersonalDataSelection\UserGdprDataService;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
Expand All @@ -25,6 +26,8 @@
use Laravel\Passport\HasApiTokens;
use Mastodon;
use Spatie\Permission\Traits\HasRoles;
use Spatie\PersonalDataExport\ExportsPersonalData;
use Spatie\PersonalDataExport\PersonalDataSelection;

/**
* // properties
Expand Down Expand Up @@ -58,6 +61,9 @@
* @property boolean muted
* @property boolean isAuthUserBlocked
* @property boolean isBlockedByAuthUser
* @property ?Carbon recent_gdpr_export
* @property Carbon created_at
* @property Carbon updated_at
*
* // relationships
* @property Collection trainCheckins
Expand All @@ -84,15 +90,15 @@
* @todo rename mapprovider to map_provider
* @mixin Builder
*/
class User extends Authenticatable implements MustVerifyEmail
class User extends Authenticatable implements MustVerifyEmail, ExportsPersonalData
{

use Notifiable, HasApiTokens, HasFactory, HasRoles;

protected $fillable = [
'username', 'name', 'avatar', 'email', 'email_verified_at', 'password', 'home_id', 'privacy_ack_at',
'default_status_visibility', 'likes_enabled', 'points_enabled', 'private_profile', 'prevent_index',
'privacy_hide_days', 'language', 'last_login', 'mapprovider', 'timezone', 'friend_checkin',
'privacy_hide_days', 'language', 'last_login', 'mapprovider', 'timezone', 'friend_checkin', 'recent_gdpr_export',
];
protected $hidden = [
'password', 'remember_token', 'email', 'email_verified_at', 'privacy_ack_at',
Expand All @@ -117,6 +123,7 @@ class User extends Authenticatable implements MustVerifyEmail
'mapprovider' => MapProvider::class,
'timezone' => 'string',
'friend_checkin' => FriendCheckinSetting::class,
'recent_gdpr_export' => 'datetime',
];

public function getTrainDistanceAttribute(): float {
Expand Down Expand Up @@ -333,4 +340,16 @@ public function preferredLocale(): string {
protected function getDefaultGuardName(): string {
return 'web';
}

public function oAuthClients(): HasMany {
return $this->hasMany(OAuthClient::class, 'user_id', 'id');
}

public function selectPersonalData(PersonalDataSelection $personalDataSelection): void {
MrKrisKrisu marked this conversation as resolved.
Show resolved Hide resolved
(new UserGdprDataService())($personalDataSelection, $this);
}

public function personalDataExportName(): string {
return $this->username;
}
}
1 change: 1 addition & 0 deletions app/Models/WebhookCreationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
class WebhookCreationRequest extends Model {
public $timestamps = false;
protected $fillable = ['id', 'user_id', 'oauth_client_id', 'revoked', 'expires_at', 'events', 'url'];
protected $hidden = ['url'];
protected $casts = [
'id' => 'string',
'user_id' => 'integer',
Expand Down
2 changes: 2 additions & 0 deletions app/Notifications/BaseNotification.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ public static function getNotice(array $data): ?string;
* @return string|null optionally link to which the user should be redirected if clicked on the notification
*/
public static function getLink(array $data): ?string;

public function toArray(): array;
}
37 changes: 37 additions & 0 deletions app/Notifications/PersonalDataExportedNotification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace App\Notifications;

use Carbon\Carbon;
use Spatie\PersonalDataExport\Notifications\PersonalDataExportedNotification as MainPersonalDataExportedNotification;

class PersonalDataExportedNotification extends MainPersonalDataExportedNotification implements BaseNotification
{

public function via($notifiable): array {
return ['mail', 'database'];
}

public static function getLead(array $data): string {
return __('notifications.personalDataExported.lead');
}

public static function getNotice(array $data): ?string {
$date = Carbon::parse($data['deletionDatetime']);
return __('notifications.personalDataExported.notice', [
'date' => userTime($date, __('datetime-format')),
]);
}

public static function getLink(array $data): ?string {
return route('personal-data-exports', $data['zipFilename']);
}

public function toArray(): array
{
return [
'zipFilename' => $this->zipFilename,
'deletionDatetime' => $this->deletionDatetime,
];
}
}
87 changes: 87 additions & 0 deletions app/Services/PersonalDataSelection/UserGdprDataService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

namespace App\Services\PersonalDataSelection;

use App\Http\Controllers\Backend\User\TokenController;
use App\Models\Event;
use App\Models\EventSuggestion;
use App\Models\Mention;
use App\Models\User;
use App\Models\WebhookCreationRequest;
use Illuminate\Support\Facades\DB;
use Spatie\PersonalDataExport\PersonalDataSelection;

class UserGdprDataService
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gerade noch festgestellt: Wenn man die .zip herunterlädt fehlt die Dateiendung. Die Datei heißt einfach nur wie der User. Da sollten wir nochmal schauen.

{
public function __invoke(PersonalDataSelection $personalDataSelection, User $data): void {
$this->addUserPersonalData($personalDataSelection, $data);
}

private function addUserPersonalData(PersonalDataSelection $personalDataSelection, User $userModel): void {
$userData = $userModel->only([
'name', 'username', 'home_id', 'private_profile', 'default_status_visibility',
'default_status_sensitivity', 'prevent_index', 'privacy_hide_days', 'language',
'timezone', 'friend_checkin', 'likes_enabled', 'points_enabled', 'mapprovider',
'email', 'email_verified_at', 'privacy_ack_at',
'last_login', 'created_at', 'updated_at'
]);

$webhooks = $userModel->webhooks()->with('events')->get();
$webhooks = $webhooks->map(function($webhook) {
return $webhook->only([
'oauth_client_id', 'created_at', 'updated_at'
]);
});


if ($userModel->avatar && file_exists(public_path('/uploads/avatars/' . $userModel->avatar))) {
$personalDataSelection
->addFile(public_path('/uploads/avatars/' . $userModel->avatar));
}

$personalDataSelection
->add('user.json', $userData)
->add('notifications.json', $userModel->notifications()->get()->toJson()) //TODO: columns definieren
->add('likes.json', $userModel->likes()->get()->toJson()) //TODO: columns definieren
->add('social_profile.json', $userModel->socialProfile()->with('mastodonserver')->get()) //TODO: columns definieren
->add('event_suggestions.json', EventSuggestion::where('user_id', $userModel->id)->get()->toJson()) //TODO: columns definieren
->add('events.json', Event::where('approved_by', $userModel->id)->get()->toJson()) //TODO: columns definieren
->add('webhooks.json', $webhooks)
->add(
'webhook_creation_requests.json',
WebhookCreationRequest::where('user_id', $userModel->id)->get()->toJson() //TODO: columns definieren
)
->add('tokens.json', TokenController::index($userModel)->toJson()) //TODO: columns definieren
->add('ics_tokens.json', $userModel->icsTokens()->get()->toJson()) //TODO: columns definieren
->add(
'password_resets.json',
DB::table('password_resets')->select(['email', 'created_at'])->where('email', $userModel->email)->get() //TODO: columns definieren
)
->add('apps.json', $userModel->oAuthClients()->get()->toJson()) //TODO: columns definieren
->add('follows.json', DB::table('follows')->where('user_id', $userModel->id)->get()) //TODO: columns definieren
->add('followings.json', DB::table('follows')->where('follow_id', $userModel->id)->get()) //TODO: columns definieren
->add('blocks.json', DB::table('user_blocks')->where('user_id', $userModel->id)->get()) //TODO: columns definieren
->add('mutes.json', DB::table('user_mutes')->where('user_id', $userModel->id)->get()) //TODO: columns definieren
->add('follow_requests.json', DB::table('follow_requests')->where('user_id', $userModel->id)->get()) //TODO: columns definieren
->add('follows_requests.json', DB::table('follow_requests')->where('follow_id', $userModel->id)->get()) //TODO: columns definieren
->add('sessions.json', $userModel->sessions()->get()->toJson()) //TODO: columns definieren
->add('home.json', $userModel->home()->get()->toJson()) //TODO: columns definieren
->add('hafas_trips.json', DB::table('hafas_trips')->where('user_id', $userModel->id)->get()) //TODO: columns definieren
->add('mentions.json', Mention::where('mentioned_id', $userModel->id)->get()->toJson()) //TODO: columns definieren
->add('roles.json', $userModel->roles()->get()->toJson()) //TODO: columns definieren
->add(
'activity_log.json',
DB::table('activity_log')->where('causer_type', get_class($userModel))->where('causer_id', $userModel->id)->get() //TODO: columns definieren
)
->add('permissions.json', $userModel->permissions()->get()->toJson()) //TODO: columns definieren
->add('statuses.json', $userModel->statuses()->with('tags')->get()) //TODO: columns definieren
->add(
'reports.json',
DB::table('reports')
->select('subject_type', 'subject_id', 'reason', 'description', 'reporter_id')
->where('reporter_id', $userModel->id)
->get() //TODO: columns definieren
)
->add('trusted_users.json', DB::table('trusted_users')->where('user_id', $userModel->id)->get()); //TODO: columns definieren
}
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"spatie/icalendar-generator": "^2.0",
"spatie/laravel-activitylog": "^4.7",
"spatie/laravel-permission": "^6.1",
"spatie/laravel-personal-data-export": "^4.3",
"spatie/laravel-prometheus": "^1.0",
"spatie/laravel-sitemap": "^7.0",
"spatie/laravel-validation-rules": "^3.2",
Expand Down
Loading
Loading