diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index f0a456624..c163539f6 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -538,7 +538,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, the messages and reactions you sent, and all your uploaded images are erased too; other people's messages in your conversations stay with them. Link cards elsewhere that point to the erased commits are removed as well, since they repeat their text. 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/DeleteUser.php b/app/Actions/DeleteUser.php index 479931c44..a31a2cb13 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -9,6 +9,7 @@ use App\Models\CommitImage; use App\Models\CommitReport; use App\Models\Conversation; +use App\Models\LinkPreview; use App\Models\Message; use App\Models\MessageImage; use App\Models\MessageVoice; @@ -34,7 +35,7 @@ public function __construct(private UpdateCommitHotScores $updateCommitHotScores * other people replied to: those stay behind as tombstones with no author or body, so the replies under them keep their thread. * The database cascades the image rows of erased commits and the user's scheduled commits, and the voice notes of their * messages, but the files are erased here. Orgs the user was the last one in go too, with everything sent in them, - * as they do when the last member leaves. + * as they do when the last member leaves. So do the previews of links to the erased commits, which hold their bodies. */ public function handle(User $user): void { @@ -43,13 +44,15 @@ public function handle(User $user): void $imagePaths = [...$this->imagePaths($user), ...$this->scheduledImagePaths($user), ...$this->messageImagePaths($user), ...$this->messageVoicePaths($user)]; - $translationKeys = Commit::query()->withTrashed() - ->where(fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id) - ->orWhereIn('root_id', $user->commits()->withTrashed()->topLevel()->select('id'))) + $translationKeys = $this->erasedCommits($user) ->select(['id', 'body']) ->lazyById(500) ->map(resolve(CommitTranslations::class)->key(...))->unique()->all(); + LinkPreview::query() + ->whereIn(DB::raw("substring(lower(url) from '/commits/([0-9a-f-]{36})')"), $this->erasedCommits($user)->selectRaw('id::text')) + ->delete(); + $user->commits()->withTrashed()->topLevel()->forceDelete(); CommitImage::query()->whereIn('commit_id', $this->replies($user)->select('id'))->delete(); CommitReport::query()->whereIn('commit_id', $this->replies($user)->select('id'))->delete(); @@ -95,6 +98,16 @@ private function engagedCommitIds(User $user): array ]; } + /** + * The commits that are erased or emptied with the user: their own, and every reply in the threads they started. + */ + private function erasedCommits(User $user): CommitBuilder + { + return Commit::query()->withTrashed() + ->where(fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id) + ->orWhereIn('root_id', $user->commits()->withTrashed()->topLevel()->select('id'))); + } + /** * @return HasMany */ diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index df3242c95..f1213a15f 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -51,6 +51,27 @@ expect(Commit::withTrashed()->pluck('id')->all())->toBe([$unrelated->id]); }); +it('erases the previews of links to the commits it erases, which hold their bodies', function (): void { + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create(); + $reply = Commit::factory()->for($commit, 'parent')->create(); + $tombstone = Commit::factory()->for($user)->for(Commit::factory(), 'parent')->create(); + Commit::factory()->for($tombstone, 'parent')->create(); + $previews = collect([$commit, $reply, $tombstone])->map(fn (Commit $linked): LinkPreview => LinkPreview::factory()->create([ + 'url' => route('commit.show', [$linked->user, $linked]), + 'description' => $linked->body, + ])); + $shouted = LinkPreview::factory()->create(['url' => str_replace($commit->id, mb_strtoupper($commit->id), route('commit.show', [$user, $commit]))]); + $linking = Commit::factory()->create(['link_preview_id' => $previews->first()->id]); + $other = LinkPreview::factory()->create(['url' => route('commit.show', [$linking->user, $linking])]); + + resolve(DeleteUser::class)->handle($user); + + $previews->push($shouted)->each(fn (LinkPreview $preview) => $this->assertModelMissing($preview)); + $this->assertModelExists($other); + expect($linking->fresh()->link_preview_id)->toBeNull(); +}); + it('keeps the quotes of others when erasing the quoted commits of a deleted user', function (): void { $user = User::factory()->create(); $quote = Commit::factory()->for(Commit::factory()->for($user), 'quotedCommit')->create();