From cdb60bd277ca8dfac5544e2317c08f21f3507164 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 26 Oct 2026 22:44:09 +0100 Subject: [PATCH] fix: erase sessions with the account and export everything stored about it Deleting an account left its sessions behind, each with the IP address and browser of a device, as sessions have no foreign key to cascade. The export left out the scheduled commits, the reports filed, the message reactions, and the images sent in remotes. --- KNOWLEDGE_BASE.md | 7 +- app/Actions/CreateExport.php | 88 +++++++++++++++++-- app/Actions/DeleteUser.php | 3 + .../Controllers/ExportControllerTest.php | 76 +++++++++++++++- tests/Unit/Actions/DeleteUserTest.php | 13 +++ 5 files changed, 176 insertions(+), 11 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 6231c306e..8af65ae99 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -524,11 +524,12 @@ Choose **Download JSON** in Settings → Profile to export your account data. Th - profile details and preferences, including live-stream details, translation language, the last day you used /dev/null, and when you accepted the commit rules; - your commits and replies (including pending, rejected, and deleted entries still stored), their cached translations, polls, and your votes; - stars, followers and people you follow, mutes, blocks, and dismissed **Not interested** suggestions; -- messages you sent, including links to your voice notes, and notifications; +- messages you sent, including links to your images and voice notes, the reactions you left, and notifications; +- scheduled commits, with their polls, link, and image descriptions, and the reports you filed; - SSH key fingerprints, approved devices, and your timeout history; - active-session metadata: IP address, browser information, and last activity. -Other people's replies and received messages are not included as your authored data. Message images, message reactions, reports you filed, scheduled commits, and ban records are not included either. Image and voice-note references are URLs, not an archive of the media files. Deleted commit entries do not restore content already erased after the undo window. +Other people's replies and received messages are not included as your authored data, and neither are ban records or whether a moderator dismissed your reports. Scheduled commits list their image descriptions, not their images. Image and voice-note references are URLs, not an archive of the media files. Deleted commit entries do not restore content already erased after the undo window. The export does not expose session IDs, authentication tokens, private SSH keys, or other people's poll choices. Keep the downloaded JSON private because it includes personal account information. Exports are limited to **three per hour**. @@ -536,7 +537,7 @@ Sources: [export settings](resources/js/pages/user-profile/edit.tsx), [export co ### Deleting your account -Settings → Profile → **Delete account** opens a confirmation for permanent deletion of your /dev/null account; an app can also delete it through the signed API. It does not delete your GitHub account. Your top-level commits and every reply under those threads are erased. Your replies are also removed, except that replies from other people beneath them survive under authorless `[deleted]` placeholders. Your devices, the messages and reactions you sent, and all your uploaded images are erased too; other people's messages in your conversations stay with them. Account deletion is not covered by the 30-second commit undo. +Settings → Profile → **Delete account** opens a confirmation for permanent deletion of your /dev/null account; an app can also delete it through the signed API. It does not delete your GitHub account. Your top-level commits and every reply under those threads are erased. Your replies are also removed, except that replies from other people beneath them survive under authorless `[deleted]` placeholders. Your devices, your sessions, the messages and reactions you sent, and all your uploaded images are erased too; other people's messages in your conversations stay with them. Account deletion is not covered by the 30-second commit undo. You can sign in with GitHub again later, but start from a new account rather than restoring the deleted one. Export your data first if you want a copy. Deleting from /dev/null cannot retract copies of public content held elsewhere. diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index c8c9e48c5..ba341bea5 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -6,13 +6,17 @@ use App\Enums\NotificationType; use App\Models\CommitImage; +use App\Models\CommitReport; use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\Device; use App\Models\Message; +use App\Models\MessageImage; +use App\Models\MessageReaction; use App\Models\Poll; use App\Models\PollOption; use App\Models\PushSubscription; +use App\Models\ScheduledCommit; use App\Models\User; use App\Models\UserTimeout; use App\Queries\CommitBuilder; @@ -29,7 +33,7 @@ /** * @phpstan-type ExportPoll array{commit_id: string, duration_minutes: int, opened_at: string|null, options: list, created_at: string} * @phpstan-type ExportPollVote array{commit_id: string, option: string, created_at: string} - * @phpstan-type ExportMessage array{to: string|null, body: string, voice: string|null, created_at: string} + * @phpstan-type ExportMessage array{to: string|null, body: string, images: array, voice: string|null, created_at: string} * @phpstan-type ExportUser array{handle: string} * @phpstan-type UserExport array{ * schema_version: int, @@ -45,12 +49,15 @@ * blocking: Generator, * dismissed_suggestions: Generator, * messages: Generator, + * message_reactions: array, * remote_pins: array, * remote_mutes: array, * notifications: Generator>, * ssh_keys: array, * push_subscriptions: array, * devices: array, + * scheduled_commits: array, duration: int}|null, image_descriptions: list, link_preview_url: string|null, commit_id: string|null, scheduled_at: string, submitted_at: string|null, canceled_at: string|null, created_at: string|null}>, + * reports_filed: array, * timeouts: array, * sessions: Collection * } @@ -111,6 +118,7 @@ public function handle(User $user): array 'blocking' => $this->handles($user->blocking()), 'dismissed_suggestions' => $this->handles($user->dismissedSuggestions()), 'messages' => $this->messages($user), + 'message_reactions' => $this->messageReactions($user), 'remote_mutes' => Conversation::query() ->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id') ->where('mine.user_id', $user->id) @@ -150,6 +158,37 @@ public function handle(User $user): array 'approved_at' => $device->approved_at->toIso8601String(), 'last_used_at' => $device->last_used_at?->toIso8601String(), ])->all(), + // Image files of scheduled commits are never served, so only what the user wrote about them leaves. + 'scheduled_commits' => ScheduledCommit::query() + ->where('user_id', $user->id) + ->oldest('scheduled_at') + ->oldest('id') + ->get() + ->map(fn (ScheduledCommit $scheduled): array => [ + 'body' => $scheduled->body, + 'poll' => $scheduled->poll, + 'image_descriptions' => array_column($scheduled->images ?? [], 'description'), + 'link_preview_url' => $scheduled->link_preview_url, + 'commit_id' => $scheduled->commit_id, + 'scheduled_at' => $scheduled->scheduled_at->toIso8601String(), + 'submitted_at' => $scheduled->submitted_at?->toIso8601String(), + 'canceled_at' => $scheduled->canceled_at?->toIso8601String(), + 'created_at' => $scheduled->created_at?->toIso8601String(), + ]) + ->all(), + // Whether a moderator dismissed a report stays theirs. + 'reports_filed' => CommitReport::query() + ->where('reporter_id', $user->id) + ->oldest() + ->oldest('id') + ->get() + ->map(fn (CommitReport $report): array => [ + 'commit_id' => $report->commit_id, + 'reason' => $report->reason, + 'details' => $report->details, + 'created_at' => $report->created_at->toIso8601String(), + ]) + ->all(), 'timeouts' => $user->timeouts()->oldest()->get()->map(fn (UserTimeout $timeout): array => [ 'reason' => $timeout->reason->value, 'ends_at' => $timeout->ends_at->toIso8601String(), @@ -250,22 +289,57 @@ private function stars(User $user): Generator */ private function messages(User $user): Generator { - $recipients = Conversation::query() - ->whereIn('id', ConversationParticipant::query()->select('conversation_id')->where('user_id', $user->id)) - ->with('participants.user') - ->get() - ->mapWithKeys(fn (Conversation $conversation): array => [$conversation->id => $this->recipient($user, $conversation)]); + $recipients = $this->recipients($user); - foreach (Message::query()->where('user_id', $user->id)->whereNull('event')->with('voice')->lazyById(500) as $message) { + foreach (Message::query()->where('user_id', $user->id)->whereNull('event')->with(['voice', 'images'])->lazyById(500) as $message) { yield [ 'to' => $recipients->get($message->conversation_id), 'body' => $message->body, + 'images' => $message->images->sortBy('position')->map(fn (MessageImage $image): string => route('message-image.show', $image))->values()->all(), 'voice' => $message->voice?->url(), 'created_at' => $message->created_at->toIso8601String(), ]; } } + /** + * The reactions the user left on messages, whoever sent them. + * + * @return array + */ + private function messageReactions(User $user): array + { + $recipients = $this->recipients($user); + + /** @var Collection $reactions */ + $reactions = MessageReaction::query() + ->join('messages', 'messages.id', '=', 'message_reactions.message_id') + ->where('message_reactions.user_id', $user->id) + ->oldest('message_reactions.id') + ->toBase() + ->get(['messages.conversation_id', 'message_reactions.emoji', 'message_reactions.created_at']); + + return $reactions->map(fn (object $reaction): array => [ + 'to' => $recipients->get($reaction->conversation_id), + 'emoji' => $reaction->emoji, + 'created_at' => CarbonImmutable::parse($reaction->created_at)->toIso8601String(), + ])->all(); + } + + /** + * Who each of the user's conversations is with. + * + * @return Collection + */ + private function recipients(User $user): Collection + { + return Conversation::query() + ->whereIn('id', ConversationParticipant::query()->select('conversation_id')->where('user_id', $user->id)) + ->with('participants.user') + ->get() + ->mapWithKeys(fn (Conversation $conversation): array => [$conversation->id => $this->recipient($user, $conversation)]); + } + /** * The other person in a conversation, or the org it is. */ diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 479931c44..dff8fd0a1 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -63,6 +63,9 @@ public function handle(User $user): void $this->deleteOrgs->handle($this->emptyOrgs($user)); + // Sessions have no foreign key to cascade, and keep the IP address and browser of each device. + DB::table('sessions')->where('user_id', $user->id)->delete(); + $user->delete(); $user->profile->delete(); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 23b4ff740..da79a2660 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -10,12 +10,16 @@ use App\Enums\NotificationType; use App\Models\Commit; use App\Models\CommitImage; +use App\Models\CommitReport; use App\Models\Device; use App\Models\Message; +use App\Models\MessageImage; +use App\Models\MessageReaction; use App\Models\MessageVoice; use App\Models\Notification; use App\Models\Poll; use App\Models\PollVote; +use App\Models\ScheduledCommit; use App\Models\SshKey; use App\Models\User; use App\Models\UserTimeout; @@ -130,12 +134,15 @@ 'blocking' => [], 'dismissed_suggestions' => [], 'messages' => [], + 'message_reactions' => [], 'remote_mutes' => [], 'remote_pins' => [], 'notifications' => [], 'ssh_keys' => [], 'push_subscriptions' => [], 'devices' => [], + 'scheduled_commits' => [], + 'reports_filed' => [], 'timeouts' => [], 'sessions' => [], ]); @@ -531,7 +538,7 @@ $data = json_decode($this->actingAs($user)->get(route('export'))->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data['messages'])->toBe([ - ['to' => 'nunomaduro', 'body' => 'merged it this morning', 'voice' => null, 'created_at' => now()->toIso8601String()], + ['to' => 'nunomaduro', 'body' => 'merged it this morning', 'images' => [], 'voice' => null, 'created_at' => now()->toIso8601String()], ]); }); @@ -569,3 +576,70 @@ $export = resolve(CreateExport::class)->handle($user); expect($export['push_subscriptions'])->toBe([['id' => $subscription->id]]); }); + +it('exports the images the user sent in remotes as links, in order', function (): void { + $user = User::factory()->create(); + $message = Message::factory()->for($user)->create(); + $second = MessageImage::factory()->for($message)->create(['position' => 1]); + $first = MessageImage::factory()->for($message)->create(['position' => 0]); + + $data = json_decode($this->actingAs($user)->get(route('export'))->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['messages'][0]['images'])->toBe([route('message-image.show', $first), route('message-image.show', $second)]) + ->and(json_encode($data))->not->toContain($first->path); +}); + +it('exports the reactions the user left on messages, and not the reactions of others', function (): void { + $user = User::factory()->create(); + $partner = User::factory()->create(['handle' => 'nunomaduro']); + $partner->following()->attach($user); + sendChatMessage($user, $partner, 'merged it this morning'); + $message = sendChatMessage($partner, $user, 'ship it'); + MessageReaction::factory()->create(['message_id' => $message->id, 'user_id' => $user->id, 'emoji' => '🚀']); + MessageReaction::factory()->create(['message_id' => $message->id, 'user_id' => $partner->id, 'emoji' => '👀']); + + $data = json_decode($this->actingAs($user)->get(route('export'))->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['message_reactions'])->toBe([ + ['to' => 'nunomaduro', 'emoji' => '🚀', 'created_at' => now()->toIso8601String()], + ]); +}); + +it('exports the scheduled commits of the user without the paths of their images', function (): void { + $user = User::factory()->create(); + ScheduledCommit::factory()->for($user)->create([ + 'body' => 'Launching tomorrow.', + 'poll' => ['options' => ['Yes', 'No'], 'duration' => 60], + 'images' => [['path' => 'scheduled/secret-path.webp', 'width' => 10, 'height' => 10, 'description' => 'A rocket', 'position' => 0]], + 'link_preview_url' => 'https://dev-null.sh', + 'scheduled_at' => now()->addDay(), + ]); + ScheduledCommit::factory()->create(); + + $data = json_decode($this->actingAs($user)->get(route('export'))->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['scheduled_commits'])->toBe([[ + 'body' => 'Launching tomorrow.', + 'poll' => ['options' => ['Yes', 'No'], 'duration' => 60], + 'image_descriptions' => ['A rocket'], + 'link_preview_url' => 'https://dev-null.sh', + 'commit_id' => null, + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'submitted_at' => null, + 'canceled_at' => null, + 'created_at' => now()->toIso8601String(), + ]])->and(json_encode($data))->not->toContain('secret-path'); +}); + +it('exports the reports the user filed, without whether a moderator dismissed them', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->create(); + CommitReport::factory()->for($commit)->create(['reporter_id' => $user->id, 'reason' => 'spam', 'details' => 'Same link everywhere.', 'dismissed_at' => now()]); + CommitReport::factory()->for($commit)->create(); + + $data = json_decode($this->actingAs($user)->get(route('export'))->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['reports_filed'])->toBe([ + ['commit_id' => $commit->id, 'reason' => 'spam', 'details' => 'Same link everywhere.', 'created_at' => now()->toIso8601String()], + ]); +}); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index df3242c95..2914c4b8e 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -353,3 +353,16 @@ resolve(DeleteUser::class)->handle($user); expect(PushSubscription::query()->count())->toBe(0); }); + +it('deletes the sessions of the user, and not the sessions of others', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + DB::table('sessions')->insert([ + ['id' => 'mine', 'user_id' => $user->id, 'ip_address' => '203.0.113.7', 'user_agent' => 'Mine', 'payload' => '', 'last_activity' => now()->getTimestamp()], + ['id' => 'theirs', 'user_id' => $other->id, 'ip_address' => '198.51.100.9', 'user_agent' => 'Theirs', 'payload' => '', 'last_activity' => now()->getTimestamp()], + ]); + + resolve(DeleteUser::class)->handle($user); + + expect(DB::table('sessions')->pluck('id')->all())->toBe(['theirs']); +});