diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 7e0ac1908..e5e33aa62 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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 { @@ -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(); diff --git a/app/Http/Controllers/API/v1/ExportController.php b/app/Http/Controllers/API/v1/ExportController.php index 4c08b2f2b..ddc83fa01 100644 --- a/app/Http/Controllers/API/v1/ExportController.php +++ b/app/Http/Controllers/API/v1/ExportController.php @@ -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; @@ -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) { + 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') { @@ -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']); } } diff --git a/app/Jobs/MonitoredPersonalDataExportJob.php b/app/Jobs/MonitoredPersonalDataExportJob.php new file mode 100644 index 000000000..d9a6a7397 --- /dev/null +++ b/app/Jobs/MonitoredPersonalDataExportJob.php @@ -0,0 +1,20 @@ + 'integer', ]; + protected $hidden = ['client_id', 'client_secret']; public function socialProfiles(): HasMany { return $this->hasMany(SocialLoginProfile::class, 'mastodon_server', 'id'); diff --git a/app/Models/OAuthClient.php b/app/Models/OAuthClient.php index ba6ec747b..d7c42cb99 100644 --- a/app/Models/OAuthClient.php +++ b/app/Models/OAuthClient.php @@ -29,6 +29,10 @@ class OAuthClient extends PassportClient { 'revoked' => 'bool', ]; + protected $hidden = [ + 'secret', + ]; + public static function newFactory() { return parent::newFactory(); } diff --git a/app/Models/Status.php b/app/Models/Status.php index 89417a306..06a576404 100644 --- a/app/Models/Status.php +++ b/app/Models/Status.php @@ -96,6 +96,9 @@ public function getFavoritedAttribute(): ?bool { } public function getDescriptionAttribute(): string { + if ($this->checkin === null) { + return $this->body ?? ''; + } return __('description.status', [ 'username' => $this->user->name, 'origin' => $this->checkin->originStopover->station->name . diff --git a/app/Models/User.php b/app/Models/User.php index b3947a7b6..5c164a133 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; @@ -25,6 +26,8 @@ use Laravel\Passport\HasApiTokens; use Mastodon; use Spatie\Permission\Traits\HasRoles; +use Spatie\PersonalDataExport\ExportsPersonalData; +use Spatie\PersonalDataExport\PersonalDataSelection; /** * // properties @@ -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 @@ -84,7 +90,7 @@ * @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; @@ -92,7 +98,7 @@ class User extends Authenticatable implements MustVerifyEmail 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', @@ -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 { @@ -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 { + (new UserGdprDataService())($personalDataSelection, $this); + } + + public function personalDataExportName(): string { + return $this->username; + } } diff --git a/app/Models/WebhookCreationRequest.php b/app/Models/WebhookCreationRequest.php index 4a88660a6..9624eec54 100644 --- a/app/Models/WebhookCreationRequest.php +++ b/app/Models/WebhookCreationRequest.php @@ -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', diff --git a/app/Notifications/BaseNotification.php b/app/Notifications/BaseNotification.php index b7ca17966..484ec5579 100644 --- a/app/Notifications/BaseNotification.php +++ b/app/Notifications/BaseNotification.php @@ -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; } diff --git a/app/Notifications/PersonalDataExportedNotification.php b/app/Notifications/PersonalDataExportedNotification.php new file mode 100644 index 000000000..d030ab293 --- /dev/null +++ b/app/Notifications/PersonalDataExportedNotification.php @@ -0,0 +1,37 @@ + 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, + ]; + } +} diff --git a/app/Services/PersonalDataSelection/UserGdprDataService.php b/app/Services/PersonalDataSelection/UserGdprDataService.php new file mode 100644 index 000000000..a2e442401 --- /dev/null +++ b/app/Services/PersonalDataSelection/UserGdprDataService.php @@ -0,0 +1,87 @@ +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 + } +} diff --git a/composer.json b/composer.json index 1a605b959..580242d20 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 403093895..2199fb8da 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4f5d8537f86da4edf5b487297c1f33a4", + "content-hash": "07a309ea519918b05659731b084ca4ad", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -6427,6 +6427,83 @@ ], "time": "2024-11-08T18:45:41+00:00" }, + { + "name": "spatie/laravel-personal-data-export", + "version": "4.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-personal-data-export.git", + "reference": "31bcfe10b9ea2d9c1b245f2279ba59c6f882dfdd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-personal-data-export/zipball/31bcfe10b9ea2d9c1b245f2279ba59c6f882dfdd", + "reference": "31bcfe10b9ea2d9c1b245f2279ba59c6f882dfdd", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "illuminate/filesystem": "^9.0|^10.0|^11.0", + "illuminate/queue": "^9.0|^10.0|^11.0", + "illuminate/support": "^9.0|^10.0|^11.0", + "nesbot/carbon": "^2.63|^3.0", + "php": "^8.0", + "spatie/laravel-package-tools": "^1.9", + "spatie/temporary-directory": "^2.0" + }, + "require-dev": { + "laravel/legacy-factories": "^1.0.4", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^7.0|^8.0|^9.0", + "spatie/invade": "^1.0|^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\PersonalDataExport\\PersonalDataExportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\PersonalDataExport\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Create personal data downloads in a Laravel app", + "homepage": "https://github.com/spatie/laravel-personal-data-export", + "keywords": [ + "gdpr", + "personal", + "personal-data-export", + "spatie", + "zip" + ], + "support": { + "issues": "https://github.com/spatie/laravel-personal-data-export/issues", + "source": "https://github.com/spatie/laravel-personal-data-export/tree/4.3.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2024-02-28T10:07:01+00:00" + }, { "name": "spatie/laravel-prometheus", "version": "1.2.0", diff --git a/config/filesystems.php b/config/filesystems.php index a8eb2319e..353871f91 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -43,6 +43,11 @@ 'disks' => [ + 'personal-data-exports' => [ + 'driver' => 'local', + 'root' => storage_path('app/personal-data-exports'), + ], + 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), diff --git a/config/personal-data-export.php b/config/personal-data-export.php new file mode 100644 index 000000000..2db8925f7 --- /dev/null +++ b/config/personal-data-export.php @@ -0,0 +1,41 @@ + 'personal-data-exports', + + /* + * If you want to keep the original directory structure for added files, + */ + 'keep_directory_structure' => true, + + /* + * The amount of days the exports will be available. + */ + 'delete_after_days' => 7, + + /* + * Determines whether the user should be logged in to be able + * to access the export. + */ + 'authentication_required' => true, + + /* + * The notification which will be sent to the user when the export + * has been created. + */ + 'notification' => PersonalDataExportedNotification::class, + + /* + * Configure the queue and connection used by `CreatePersonalDataExportJob` + * which will create the export. + */ + 'job' => [ + 'queue' => 'export', + 'connection' => null, + ], +]; diff --git a/config/trwl.php b/config/trwl.php index 6d8de23ba..52244a6ad 100644 --- a/config/trwl.php +++ b/config/trwl.php @@ -12,7 +12,7 @@ 'mastodon_timeout_seconds' => env("MASTODON_TIMEOUT_SECONDS", 5), # Brouter - 'brouter' => env('BROUTER', true), + 'brouter' => env('BROUTER', true), 'brouter_url' => env('BROUTER_URL', 'https://brouter.de/'), 'brouter_timeout' => env('BROUTER_TIMEOUT', 10), @@ -63,4 +63,9 @@ ], 'webhooks_active' => env('WEBHOOKS_ACTIVE', false), 'webfinger_active' => env('WEBFINGER_ACTIVE', false), + + # A/B Testing + 'ab_testing' => [ + 'gdpr_export' => env('AB_TESTING_GDPR_EXPORT', false), + ] ]; diff --git a/database/migrations/2024_11_04_223623_add_recent_gdpr_export_to_users.php b/database/migrations/2024_11_04_223623_add_recent_gdpr_export_to_users.php new file mode 100644 index 000000000..f5d71c7d1 --- /dev/null +++ b/database/migrations/2024_11_04_223623_add_recent_gdpr_export_to_users.php @@ -0,0 +1,20 @@ +timestamp('recent_gdpr_export')->nullable()->after('last_login'); + }); + } + + public function down(): void { + Schema::table('users', function(Blueprint $table) { + $table->dropColumn('recent_gdpr_export'); + }); + } +}; diff --git a/database/seeders/Constants/PermissionSeeder.php b/database/seeders/Constants/PermissionSeeder.php index 821820eca..239b56596 100644 --- a/database/seeders/Constants/PermissionSeeder.php +++ b/database/seeders/Constants/PermissionSeeder.php @@ -20,6 +20,7 @@ public function run(): void { $roleClosedBeta = Role::updateOrCreate(['name' => 'closed-beta']); $roleDisallowManualTrips = Role::updateOrCreate(['name' => 'disallow-manual-trips']); $roleDeactivateAccountUsage = Role::updateOrCreate(['name' => 'deactivate-account-usage']); + $roleTestGdprExport = Role::updateOrCreate(['name' => 'test-gdpr-export']); //TODO: remove this permission when GDPR export is no longer in testing //Create permissions $permissionViewBackend = Permission::updateOrCreate(['name' => 'view-backend']); @@ -72,7 +73,7 @@ public function run(): void { $roleEventModerator->givePermissionTo($permissionUpdateEvents); //Revoke permissions from closed-beta role - $roleClosedBeta->revokePermissionTo($permissionCreateManualTrip); //now in open-beta + $roleClosedBeta->revokePermissionTo($permissionCreateManualTrip); //now in open-beta //Assign permissions to open-beta role $roleOpenBeta->givePermissionTo($permissionCreateManualTrip); diff --git a/lang/de.json b/lang/de.json index 0fbf5fe5e..29bfa224d 100644 --- a/lang/de.json +++ b/lang/de.json @@ -158,8 +158,13 @@ "export.end": "Bis", "export.lead": "Hier kannst du deine Fahrten aus der Datenbank als CSV, JSON und als PDF exportieren.", "export.error.time": "Du kannst nur Fahrten über einen Zeitraum von maximal 365 Tagen exportieren.", + "export.error.gdpr-time": "Du kannst nur einmal alle 30 Tage deine gesamten Daten exportieren. Dein letzter Export war am :date.", "export.error.amount": "Du hast mehr als 2000 Fahrten angefragt. Bitte versuche den Zeitraum einzuschränken.", + "export.gdpr": "DSGVO-Export", + "export.gdpr.description": "Hier kannst du deine gesamten Daten exportieren. Dieser Vorgang kann bis zu 48 Stunden dauern.", + "export.gdpr.recent": "Dein letzter Export war am :date. Du kannst deine Daten alle 30 Tage exportieren.", "export.submit": "Exportieren als", + "export.request": "Export anfordern", "export.title": "Exportieren", "export.type": "Zugart", "export.number": "Zugnummer", @@ -268,6 +273,8 @@ "notifications.mastodon-server.exception": "Wir konnten keine erfolgreiche Verbindung zu deiner Mastodon-Instanz :domain herstellen. Bitte überprüfe die Einstellungen oder verbinde dich erneut. Sollte das Problem weiterhin bestehen, kontaktiere bitte unseren Support.", "notifications.userJoinedConnection.lead": "@:username ist auch in Deiner Verbindung!", "notifications.userJoinedConnection.notice": "@:username reist mit :linename von :origin nach :destination.|@:username reist mit Linie :linename von :origin nach :destination.", + "notifications.personalDataExported.lead": "Dein Export ist bereit!", + "notifications.personalDataExported.notice": "Die Daten stehen für dich bis :date zum Download bereit.", "notifications.userMentioned.lead": "Du wurdest in einem Status erwähnt.", "notifications.userMentioned.notice": "Du wurdest in einem Status von @:username erwähnt.", "pagination.next": "Nächste Seite »", diff --git a/lang/en.json b/lang/en.json index f1ff57fb2..34c95f3ba 100644 --- a/lang/en.json +++ b/lang/en.json @@ -137,8 +137,13 @@ "export.end": "End", "export.lead": "You can export your journeys into a CSV, JSON or PDF file here.", "export.submit": "Export as", + "export.request": "Request", "export.error.time": "You can only export trips over a maximum period of 365 days.", + "export.error.gdpr-time": "You can only export your full data once every 30 days. Your last export was on :date.", "export.error.amount": "You have requested more than 2000 trips. Please try to limit the period.", + "export.gdpr": "GDPR-Export", + "export.gdpr.description": "Here you can export all your data. This process can take up to 48 hours.", + "export.gdpr.recent": "Your last export was on :date. You can export your data every 30 days.", "export.title": "Export data", "export.type": "Type", "export.number": "Number", @@ -249,6 +254,8 @@ "notifications.userJoinedConnection.notice": "@:username is on :linename from :origin to :destination.|@:username is on line :linename from :origin to :destination.", "notifications.userMentioned.lead": "You were mentioned in a status.", "notifications.userMentioned.notice": "You were mentioned in a status by @:username.", + "notifications.personalDataExported.lead": "Your export is ready!", + "notifications.personalDataExported.notice": "Your data will be available for download until :date.", "pagination.next": "Next »", "pagination.previous": "« Previous", "pagination.back": "« Back", diff --git a/resources/views/export.blade.php b/resources/views/export.blade.php index 7e4a825c8..38c43f7a5 100644 --- a/resources/views/export.blade.php +++ b/resources/views/export.blade.php @@ -12,6 +12,7 @@
+ @csrf
@@ -135,6 +136,7 @@ class="form-control"/>
+ @csrf
@@ -165,6 +167,43 @@ class="form-control"/>
+ @if(config('trwl.ab_testing.gdpr_export') || auth()->user()->hasRole('test-gdpr-export')) + +
+
+

+   + {{ __('export.gdpr') }} +

+ + {{__('export.gdpr.description')}} +
+ @php + $recent = auth()->user()->recent_gdpr_export; + @endphp + + @if($recent) + {{ __('export.gdpr.recent', ['date' => userTime($recent, __('datetime-format'))]) }} + @endif + +
+ +
+ + @csrf +
+
+ +
+
+
+
+
+ @endif
diff --git a/resources/vue/components/NotificationEntry.vue b/resources/vue/components/NotificationEntry.vue index c01beee87..be1b1c22d 100644 --- a/resources/vue/components/NotificationEntry.vue +++ b/resources/vue/components/NotificationEntry.vue @@ -45,6 +45,8 @@ export default { return 'fa fa-train'; case 'UserMentioned': return 'fas fa-at'; + case 'PersonalDataExportedNotification': + return 'fas fa-download'; default: return 'far fa-envelope'; } diff --git a/routes/api.php b/routes/api.php index 4e2ee772f..fcfb4ac72 100644 --- a/routes/api.php +++ b/routes/api.php @@ -106,6 +106,7 @@ }); Route::group(['prefix' => 'export', 'middleware' => 'scope:write-exports'], static function() { Route::post('statuses', [ExportController::class, 'generateStatusExport']); //TODO: undocumented endpoint - document when stable + Route::post('gdpr', [ExportController::class, 'requestGdprExport']); //TODO: undocumented endpoint - document when stable }); Route::group(['prefix' => 'user'], static function() { Route::group(['middleware' => ['scope:write-follows']], static function() { @@ -113,9 +114,9 @@ Route::delete('/{userId}/follow', [FollowController::class, 'destroyFollow']); }); Route::group(['middleware' => ['scope:write-followers']], static function() { - Route::delete('removeFollower', [FollowController::class, 'removeFollower']); // TODO remove after 2024-10 + Route::delete('removeFollower', [FollowController::class, 'removeFollower']); // TODO remove after 2024-10 Route::delete('rejectFollowRequest', [FollowController::class, 'rejectFollowRequest']); // TODO remove after 2024-10 - Route::put('approveFollowRequest', [FollowController::class, 'approveFollowRequest']); // TODO remove after 2024-10 + Route::put('approveFollowRequest', [FollowController::class, 'approveFollowRequest']); // TODO remove after 2024-10 }); Route::group(['middleware' => ['scope:write-blocks']], static function() { Route::post('/{userId}/block', [UserController::class, 'createBlock']); @@ -160,9 +161,9 @@ Route::delete('token', [TokenController::class, 'revokeToken']); //TODO: undocumented endpoint - document when stable }); Route::group(['middleware' => ['scope:read-settings-followers']], static function() { - Route::get('followers', [FollowController::class, 'getFollowers']); // TODO remove after 2024-10 + Route::get('followers', [FollowController::class, 'getFollowers']); // TODO remove after 2024-10 Route::get('follow-requests', [FollowController::class, 'getFollowRequests']); // TODO remove after 2024-10 - Route::get('followings', [FollowController::class, 'getFollowings']); // TODO remove after 2024-10 + Route::get('followings', [FollowController::class, 'getFollowings']); // TODO remove after 2024-10 }); }); Route::group(['prefix' => 'webhooks'], static function() { diff --git a/routes/web.php b/routes/web.php index 6b59c0ec5..faddeb94a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -89,6 +89,7 @@ * These routes can be used by logged in users although they have not signed the privacy policy yet. */ Route::middleware(['auth'])->group(function() { + Route::personalDataExports('personal-data-exports'); Route::get('/gdpr-intercept', [PrivacyAgreementController::class, 'intercept']) ->name('gdpr.intercept'); diff --git a/update.sh b/update.sh index 84bbc4743..0208721a2 100755 --- a/update.sh +++ b/update.sh @@ -31,6 +31,10 @@ restart_queue() { if [ -f /etc/systemd/system/traewelling-queue-webhook.service ]; then sudo systemctl restart traewelling-queue-webhook fi + + if [ -f /etc/systemd/system/traewelling-queue-export.service ]; then + sudo systemctl restart traewelling-queue-export + fi } pre_run