Skip to content
Merged
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
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ Sources: [safety settings](resources/js/pages/safety/edit.tsx), [mute action](ap

### Reporting commits

Report another person's visible, approved commit or reply privately from its controls. Choose a reason (spam or misleading links, harassment or hate, harmful or private information, or something else) and optionally add up to **500 characters** of detail. You can report a particular commit once, and file up to five reports a minute. Reporting does not immediately hide or reject the content. Moderators review reports without the reporter's identity and can dismiss the report or reject the commit, with an optional reason sent to its author. Deleting a reported commit keeps its reports. This is a content-safety report, not a software bug report; use `/bug` for app defects.
Report another person's visible, approved commit or reply privately from its controls. Choose a reason (spam or misleading links, harassment or hate, harmful or private information, or something else) and optionally add up to **500 characters** of detail. Attach up to **three JPEG, PNG, or WebP images**, each up to **10 MB**, using the image picker, paste, or drag and drop. Attachments can include descriptions and are visible only to you and moderators. You can report a particular commit once, and file up to five reports a minute. Reporting does not immediately hide or reject the content. Moderators review reports without the reporter's identity and can dismiss the report or reject the commit, with an optional reason sent to its author. Deleting a reported commit keeps its reports. This is a content-safety report, not a software bug report; use `/bug` for app defects.

Sources: [report control](resources/js/components/commit-report-button.tsx), [report validation](app/Http/Requests/CreateCommitReportRequest.php), [reported-commit controller](app/Http/Controllers/ReportedCommitController.php).

Expand Down Expand Up @@ -593,7 +593,7 @@ Choose **Download JSON** in Settings → Profile to export your account data. Th
- 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. Message images, message reactions, scheduled commits, and ban records are not included either. Reports you filed include their reason, details, and attachment references; reports filed by other people are excluded. 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**.

Expand Down
39 changes: 31 additions & 8 deletions app/Actions/CreateCommitReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,40 @@
use App\Models\Commit;
use App\Models\CommitReport;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;

final readonly class CreateCommitReport
{
public function handle(User $user, Commit $commit, string $reason, ?string $details): void
public function __construct(private CreateImageFiles $createImageFiles)
{
CommitReport::query()->createOrFirst([
'commit_id' => $commit->id,
'reporter_id' => $user->id,
], [
'reason' => $reason,
'details' => $details,
]);
//
}

/**
* @param list<UploadedFile> $images
* @param array<int, string|null> $imageDescriptions
*/
public function handle(User $user, Commit $commit, string $reason, ?string $details, array $images = [], array $imageDescriptions = []): void
{
DB::transaction(function () use ($user, $commit, $reason, $details, $images, $imageDescriptions): void {
// Match account deletion's lock order and recheck models that may have been deleted since the request began.
User::query()->whereKey($user->id)->sharedLock()->firstOrFail();
Commit::query()->whereKey($commit->id)->sharedLock()->firstOrFail();

$report = CommitReport::query()->createOrFirst([
'commit_id' => $commit->id,
'reporter_id' => $user->id,
], [
'reason' => $reason,
'details' => $details,
]);

if ($report->wasRecentlyCreated && $images !== []) {
$report->forceFill([
'images' => $this->createImageFiles->handle($images, $imageDescriptions, 'report-images', encrypted: true),
])->save();
}
});
}
}
10 changes: 10 additions & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Enums\NotificationType;
use App\Models\CommitImage;
use App\Models\CommitReport;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Device;
Expand All @@ -25,6 +26,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\LazyCollection;
use stdClass;
use Traversable;

/**
* @phpstan-type ExportPoll array{commit_id: string, duration_minutes: int, opened_at: string|null, options: list<string>, created_at: string}
Expand All @@ -46,6 +48,7 @@
* dismissed_suggestions: Generator<int, ExportUser>,
* claimed_organizations: array<int, array{handle: string, claimed_at: string}>,
* messages: Generator<int, ExportMessage>,
* reports: Traversable<int, array<string, mixed>>,
* remote_pins: array<int, array{to: string|null, pinned_at: string|null}>,
* remote_mutes: array<int, array{to: string|null, muted_until_at: string|null}>,
* notifications: Generator<int, array<string, int|string|null>>,
Expand Down Expand Up @@ -113,6 +116,13 @@ public function handle(User $user): array
'dismissed_suggestions' => $this->handles($user->dismissedSuggestions()),
'claimed_organizations' => $this->claimedOrganizations($user),
'messages' => $this->messages($user),
'reports' => CommitReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (CommitReport $report): array => [
'commit_id' => $report->commit_id,
'reason' => $report->reason,
'details' => $report->details,
'attachments' => $report->toArray()['attachments'],
'created_at' => $report->created_at->toIso8601String(),
])->getIterator(),
'remote_mutes' => Conversation::query()
->join('conversation_participants as mine', 'mine.conversation_id', '=', 'conversations.id')
->where('mine.user_id', $user->id)
Expand Down
11 changes: 9 additions & 2 deletions app/Actions/CreateImageFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use RuntimeException;

final readonly class CreateImageFiles
{
Expand Down Expand Up @@ -46,10 +47,16 @@ public function handle(array $images, array $descriptions = [], string $director
if ($encrypted) {
$path = $directory.'/'.Str::uuid7()->toString();
$stored = Storage::disk()->put($path, $this->encryptedFiles->encrypt($image->toBytes()));
assert($stored);

if (! $stored) {
throw new RuntimeException('Unable to store image.');
}
} else {
$path = $image->storeAs($directory, Str::uuid7()->toString().'.webp');
assert(is_string($path));

if (! is_string($path)) {
throw new RuntimeException('Unable to store image.');
}
}

DB::afterRollBack(fn (): bool => Storage::disk()->delete($path));
Expand Down
6 changes: 2 additions & 4 deletions app/Actions/DeleteOrgs.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
use App\Models\MessageImage;
use App\Models\MessageVoice;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

final readonly class DeleteOrgs
{
Expand All @@ -29,8 +27,8 @@ public function handle(Builder $orgs): void
...MessageVoice::query()->whereIn('message_id', $messageIds)->get(['path'])->map(fn (MessageVoice $voice): string => $voice->path),
];

$orgs->delete();
resolve(DeleteStoredFiles::class)->retain($paths);

DB::afterCommit(fn (): bool => Storage::disk()->delete($paths));
$orgs->delete();
}
}
76 changes: 76 additions & 0 deletions app/Actions/DeleteStoredFiles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
use UnexpectedValueException;

final readonly class DeleteStoredFiles
{
/**
* Call inside the transaction removing the file metadata. The cleanup survives a crash after commit,
* and rolls back with the metadata if deletion of the account fails.
*
* @param array<int, string> $paths
*/
public function retain(array $paths): void
{
if ($paths === []) {
return;
}

$id = DB::table('pending_file_deletions')->insertGetId([
'disk' => config()->string('filesystems.default'),
'paths' => json_encode(array_values(array_unique($paths)), JSON_THROW_ON_ERROR),
]);

DB::afterCommit(fn () => $this->handle($id));
}

public function handle(int $id): void
{
$pending = DB::table('pending_file_deletions')->find($id);

if ($pending === null) {
return;
}

try {
if (! is_object($pending)
|| ! isset($pending->paths, $pending->disk)
|| ! is_string($pending->paths)
|| ! is_string($pending->disk)) {
throw new UnexpectedValueException('Invalid pending file deletion record '.$id);
}

$decodedPaths = json_decode($pending->paths, true, flags: JSON_THROW_ON_ERROR);

if (! is_array($decodedPaths) || ! array_is_list($decodedPaths)) {
throw new UnexpectedValueException('Invalid paths for pending file deletion '.$id);
}

$paths = [];
foreach ($decodedPaths as $path) {
if (! is_string($path)) {
throw new UnexpectedValueException('Invalid path for pending file deletion '.$id);
}

$paths[] = $path;
}

if (! Storage::disk($pending->disk)->delete($paths)) {
throw new RuntimeException('File cleanup failed for pending deletion '.$id);
}

DB::table('pending_file_deletions')->where('id', $id)->delete();
} catch (Throwable $throwable) {
// Keep the durable record for the scheduled retry, including when the disk throws.
report($throwable);
}
}
}
27 changes: 19 additions & 8 deletions app/Actions/DeleteUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

final readonly class DeleteUser
{
Expand All @@ -38,10 +37,10 @@ public function __construct(private UpdateCommitHotScores $updateCommitHotScores
*/
public function handle(User $user): void
{
$imagePaths = DB::transaction(function () use ($user): array {
$this->updateCommitHotScores->handle($this->engagedCommitIds($user));
DB::transaction(function () use ($user): void {
User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail();

$imagePaths = [...$this->imagePaths($user), ...$this->scheduledImagePaths($user), ...$this->messageImagePaths($user), ...$this->messageVoicePaths($user)];
$this->updateCommitHotScores->handle($this->engagedCommitIds($user));

$translationKeys = $this->touchedCommits($user)
->select(['id', 'body'])
Expand All @@ -53,6 +52,10 @@ public function handle(User $user): void
$user->commits()->withTrashed()->topLevel()->orderBy('id')->lockForUpdate()->pluck('id');
$this->touchedCommits($user)->orderBy('id')->lockForUpdate()->pluck('id');

// Report creation holds shared reporter and commit locks until its attachment paths are saved.
$imagePaths = [...$this->reportImagePaths($user), ...$this->imagePaths($user), ...$this->scheduledImagePaths($user), ...$this->messageImagePaths($user), ...$this->messageVoicePaths($user)];
resolve(DeleteStoredFiles::class)->retain($imagePaths);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
DB::table('commit_topics')->whereIn('commit_id', $this->touchedCommits($user)->select('id'))->delete();
$user->commits()->withTrashed()->topLevel()->forceDelete();
CommitImage::query()->whereIn('commit_id', $this->replies($user)->select('id'))->delete();
Expand All @@ -75,11 +78,7 @@ public function handle(User $user): void
foreach ($translationKeys as $key) {
Cache::forget($key);
}

return $imagePaths;
});

Storage::disk()->delete($imagePaths);
}

/**
Expand Down Expand Up @@ -119,6 +118,18 @@ private function replies(User $user): HasMany
return $user->commits()->withTrashed()->whereNotNull('parent_id');
}

/** @return array<int, string> */
private function reportImagePaths(User $user): array
{
return CommitReport::query()
->where('reporter_id', $user->id)
->orWhereIn('commit_id', $user->commits()->withTrashed()->select('id'))
->orWhereIn('commit_id', Commit::query()->withTrashed()->whereIn('root_id', $user->commits()->withTrashed()->topLevel()->select('id'))->select('id'))
->get(['images'])
->flatMap(fn (CommitReport $report): array => array_column($report->images ?? [], 'path'))
->all();
}

/**
* The images of the user's commits and of every reply in the threads they started, which go along with them.
*
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/CommitReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
{
public function store(CreateCommitReportRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommitReport $action): RedirectResponse
{
$action->handle($user, $commit, $request->string('reason')->value(), $request->filled('details') ? $request->string('details')->value() : null);
$action->handle($user, $commit, $request->string('reason')->value(), $request->filled('details') ? $request->string('details')->value() : null, $request->images(), $request->imageDescriptions());

Inertia::flash('toast', ['type' => 'success', 'message' => 'Report received. Moderators will review it privately.']);

Expand Down
28 changes: 28 additions & 0 deletions app/Http/Controllers/CommitReportImageController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\ShowCommitReportImageRequest;
use App\Models\CommitReport;
use App\Services\EncryptedFiles;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;

final readonly class CommitReportImageController
{
public function show(ShowCommitReportImageRequest $request, CommitReport $report, int $image, EncryptedFiles $encryptedFiles): Response
{
$file = $report->images[$image] ?? null;
assert(is_array($file));

return response($encryptedFiles->decrypt((string) Storage::disk()->get($file['path'])), headers: [
'Content-Type' => 'image/webp',
'Content-Disposition' => 'inline',
'Content-Security-Policy' => "default-src 'none'; sandbox",
'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'private, no-store',
]);
}
}
25 changes: 24 additions & 1 deletion app/Http/Requests/CreateCommitReportRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@
use App\Models\CommitReport;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Dimensions;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\File;
use Illuminate\Validation\Rules\In;

final class CreateCommitReportRequest extends FormRequest
Expand All @@ -36,13 +41,31 @@ public function authorize(): Response
}

/**
* @return array{reason: array<int, string|In>, details: array<int, string>}
* @return array<string, array<int, string|In|ValidationRule|Dimensions|Enum|File>>
*/
public function rules(): array
{
return [
...array_intersect_key(CreateCommitRequest::attachmentRules(), array_flip(['images', 'images.*', 'image_descriptions', 'image_descriptions.*'])),
'reason' => ['required', 'string', Rule::in(array_keys(CommitReport::REASONS))],
'details' => ['nullable', 'string', 'max:500'],
];
}

/** @return list<UploadedFile> */
public function images(): array
{
$images = $this->file('images');

return is_array($images) ? array_values($images) : [];
}

/** @return list<string|null> */
public function imageDescriptions(): array
{
/** @var list<string|null> $descriptions */
$descriptions = $this->array('image_descriptions');

return $descriptions;
}
}
Loading
Loading