Skip to content
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: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 17 additions & 4 deletions app/Actions/DeleteUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand All @@ -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'))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
->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();
Expand Down Expand Up @@ -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<Commit, User>
*/
Expand Down
21 changes: 21 additions & 0 deletions tests/Unit/Actions/DeleteUserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down