From 494db376afa86b33ae671c490e838223e8f5ab35 Mon Sep 17 00:00:00 2001 From: Sheraz Manzoor Date: Sat, 24 Oct 2026 14:34:44 +0500 Subject: [PATCH 01/11] feat: store private image attachments on commit reports --- app/Actions/CreateCommitReport.php | 35 +++++++++--- app/Actions/CreateExport.php | 32 ++++------- app/Actions/DeleteUser.php | 14 ++++- .../Controllers/CommitReportController.php | 2 +- .../CommitReportImageController.php | 28 ++++++++++ .../Requests/CreateCommitReportRequest.php | 25 ++++++++- .../Requests/ShowCommitReportImageRequest.php | 33 ++++++++++++ app/Models/CommitReport.php | 19 ++++++- app/Queries/ReportedCommitsQuery.php | 2 +- ...000_add_images_to_commit_reports_table.php | 17 ++++++ routes/web.php | 2 + .../CommitReportControllerTest.php | 54 +++++++++++++++++++ .../Controllers/ExportControllerTest.php | 21 ++++++++ tests/Unit/Actions/DeleteUserTest.php | 16 ++++++ 14 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/CommitReportImageController.php create mode 100644 app/Http/Requests/ShowCommitReportImageRequest.php create mode 100644 database/migrations/2026_10_24_100000_add_images_to_commit_reports_table.php diff --git a/app/Actions/CreateCommitReport.php b/app/Actions/CreateCommitReport.php index 0cb9a2131..a39eec2f8 100644 --- a/app/Actions/CreateCommitReport.php +++ b/app/Actions/CreateCommitReport.php @@ -7,17 +7,36 @@ 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 $images + * @param array $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 { + $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(); + } + }); } } diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index e3cc1122a..9343b4314 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -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; @@ -24,34 +25,14 @@ 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, 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 ExportUser array{username: string} - * @phpstan-type UserExport array{ - * schema_version: int, - * exported_at: string, - * profile: array, - * commits: Generator>, - * polls: Generator, - * poll_votes: Generator, - * stars: Generator, - * following: Generator, - * followers: Generator, - * muting: Generator, - * blocking: Generator, - * dismissed_suggestions: Generator, - * messages: Generator, - * remote_pins: array, - * remote_mutes: array, - * notifications: Generator>, - * ssh_keys: array, - * devices: array, - * timeouts: array, - * sessions: Collection - * } + * @phpstan-type UserExport array{schema_version: int, exported_at: string, profile: array, commits: Generator>, polls: Generator, poll_votes: Generator, stars: Generator, following: Generator, followers: Generator, muting: Generator, blocking: Generator, dismissed_suggestions: Generator, messages: Generator, reports: Traversable>, remote_pins: array, remote_mutes: array, notifications: Generator>, ssh_keys: array, devices: array, timeouts: array, sessions: Collection} */ final readonly class CreateExport { @@ -108,6 +89,13 @@ public function handle(User $user): array 'blocking' => $this->usernames($user->blocking()), 'dismissed_suggestions' => $this->usernames($user->dismissedSuggestions()), '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) diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 9fb528780..f8bc60f1c 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -41,7 +41,7 @@ public function handle(User $user): void $imagePaths = DB::transaction(function () use ($user): array { $this->updateCommitHotScores->handle($this->engagedCommitIds($user)); - $imagePaths = [...$this->imagePaths($user), ...$this->scheduledImagePaths($user), ...$this->messageImagePaths($user), ...$this->messageVoicePaths($user)]; + $imagePaths = [...$this->reportImagePaths($user), ...$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) @@ -102,6 +102,18 @@ private function replies(User $user): HasMany return $user->commits()->withTrashed()->whereNotNull('parent_id'); } + /** @return array */ + 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. * diff --git a/app/Http/Controllers/CommitReportController.php b/app/Http/Controllers/CommitReportController.php index 267d0a5b1..f07230cfa 100644 --- a/app/Http/Controllers/CommitReportController.php +++ b/app/Http/Controllers/CommitReportController.php @@ -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.']); diff --git a/app/Http/Controllers/CommitReportImageController.php b/app/Http/Controllers/CommitReportImageController.php new file mode 100644 index 000000000..5448d44e5 --- /dev/null +++ b/app/Http/Controllers/CommitReportImageController.php @@ -0,0 +1,28 @@ +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', + ]); + } +} diff --git a/app/Http/Requests/CreateCommitReportRequest.php b/app/Http/Requests/CreateCommitReportRequest.php index ea45f920c..5af15eec4 100644 --- a/app/Http/Requests/CreateCommitReportRequest.php +++ b/app/Http/Requests/CreateCommitReportRequest.php @@ -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 @@ -36,13 +41,31 @@ public function authorize(): Response } /** - * @return array{reason: array, details: array} + * @return array> */ 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 */ + public function images(): array + { + $images = $this->file('images'); + + return is_array($images) ? array_values($images) : []; + } + + /** @return list */ + public function imageDescriptions(): array + { + /** @var list $descriptions */ + $descriptions = $this->array('image_descriptions'); + + return $descriptions; + } } diff --git a/app/Http/Requests/ShowCommitReportImageRequest.php b/app/Http/Requests/ShowCommitReportImageRequest.php new file mode 100644 index 000000000..a25b8cfff --- /dev/null +++ b/app/Http/Requests/ShowCommitReportImageRequest.php @@ -0,0 +1,33 @@ +user(); + assert($user instanceof User); + $report = $this->route('report'); + assert($report instanceof CommitReport); + $image = $report->images[(int) $this->route('image')] ?? null; + + return ($user->isAdmin() || $report->reporter_id === $user->id) && $image !== null && Storage::disk()->exists($image['path']) + ? Response::allow() + : Response::denyAsNotFound(); + } + + /** @return array{} */ + public function rules(): array + { + return []; + } +} diff --git a/app/Models/CommitReport.php b/app/Models/CommitReport.php index 5705e10bf..258755fca 100644 --- a/app/Models/CommitReport.php +++ b/app/Models/CommitReport.php @@ -7,6 +7,7 @@ use Carbon\CarbonInterface; use Database\Factories\CommitReportFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,12 +18,14 @@ * @property-read string $commit_id * @property-read string $reporter_id * @property-read string $reason + * @property-read list|null $images * @property-read string|null $details * @property-read CarbonInterface|null $dismissed_at * @property-read CarbonInterface $created_at * @property-read CarbonInterface $updated_at * @property-read Commit|null $commit */ +#[Hidden(['images'])] #[Fillable(['commit_id', 'reporter_id', 'reason', 'details'])] final class CommitReport extends Model { @@ -41,7 +44,21 @@ final class CommitReport extends Model /** @return array */ public function casts(): array { - return ['dismissed_at' => 'datetime']; + return ['dismissed_at' => 'datetime', 'images' => 'encrypted:array']; + } + + /** @return array */ + public function toArray(): array + { + return [ + ...parent::toArray(), + 'attachments' => array_map(fn (array $image): array => [ + 'url' => route('commit-report-image.show', ['report' => $this->id, 'image' => $image['position']]), + 'description' => $image['description'], + 'width' => $image['width'], + 'height' => $image['height'], + ], $this->images ?? []), + ]; } /** @return BelongsTo */ diff --git a/app/Queries/ReportedCommitsQuery.php b/app/Queries/ReportedCommitsQuery.php index 85cc3a8b8..4a1a93abf 100644 --- a/app/Queries/ReportedCommitsQuery.php +++ b/app/Queries/ReportedCommitsQuery.php @@ -16,7 +16,7 @@ public function paginate(User $viewer): CursorPaginator { return CommitReport::query() - ->select(['id', 'commit_id', 'reason', 'details', 'created_at']) + ->select(['id', 'commit_id', 'reason', 'details', 'images', 'created_at']) ->whereNull('dismissed_at') ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->approved()->visibleTo($viewer)) ->with(['commit' => fn (BelongsTo $query): BelongsTo => $query diff --git a/database/migrations/2026_10_24_100000_add_images_to_commit_reports_table.php b/database/migrations/2026_10_24_100000_add_images_to_commit_reports_table.php new file mode 100644 index 000000000..a38c37c8f --- /dev/null +++ b/database/migrations/2026_10_24_100000_add_images_to_commit_reports_table.php @@ -0,0 +1,17 @@ +text('images')->nullable(); + }); + } +}; diff --git a/routes/web.php b/routes/web.php index c8a19d28d..3342c6a70 100644 --- a/routes/web.php +++ b/routes/web.php @@ -24,6 +24,7 @@ use App\Http\Controllers\CommitRejectionController; use App\Http\Controllers\CommitReplyController; use App\Http\Controllers\CommitReportController; +use App\Http\Controllers\CommitReportImageController; use App\Http\Controllers\CommitRestorationController; use App\Http\Controllers\CommitSearchController; use App\Http\Controllers\CommitSitemapController; @@ -375,6 +376,7 @@ // Moderation... Route::get('moderation', [ModerationController::class, 'show'])->name('moderation.show'); + Route::get('moderation/reports/{report}/images/{image}', [CommitReportImageController::class, 'show'])->whereUuid('report')->whereIn('image', ['0', '1', '2'])->name('commit-report-image.show'); Route::get('moderation/reports', [ReportedCommitController::class, 'index'])->name('reported-commit.index'); Route::delete('moderation/reports/{report}', [CommitReportController::class, 'destroy'])->name('commit-report.destroy'); Route::post('moderation/{commit}/approval', [CommitApprovalController::class, 'store']) diff --git a/tests/Feature/Controllers/CommitReportControllerTest.php b/tests/Feature/Controllers/CommitReportControllerTest.php index 503d33667..831a84b6d 100644 --- a/tests/Feature/Controllers/CommitReportControllerTest.php +++ b/tests/Feature/Controllers/CommitReportControllerTest.php @@ -10,8 +10,11 @@ use App\Models\Poll; use App\Models\PollVote; use App\Models\User; +use App\Services\EncryptedFiles; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; beforeEach(function (): void { @@ -404,3 +407,54 @@ ->where('reports.data.0.commit.parent', null) ->missing('reports.data.0.reporter_id')); })->with([false, true]); + +it('stores report screenshots encrypted and only serves them to moderators', function (): void { + Storage::fake(); + $reporter = User::factory()->create(); + $commit = Commit::factory()->create(); + $this->actingAs($reporter)->post(route('commit-report.store', $commit), [ + 'reason' => 'other', + 'images' => [UploadedFile::fake()->image('evidence.png', 200, 100)], + 'image_descriptions' => ['Evidence screenshot'], + ])->assertRedirect(); + + $report = CommitReport::query()->sole(); + expect($report->images)->toHaveCount(1); + $file = $report->images[0]; + $bytes = Storage::disk()->get($file['path']); + expect(str_starts_with($bytes, 'RIFF'))->toBeFalse(); + expect(str_starts_with(resolve(EncryptedFiles::class)->decrypt($bytes), 'RIFF'))->toBeTrue(); + + $url = route('commit-report-image.show', ['report' => $report, 'image' => 0]); + expect(DB::table('commit_reports')->where('id', $report->id)->value('images'))->not->toContain('Evidence screenshot', $file['path']); + $this->get($url)->assertOk(); + $this->actingAs(User::factory()->create())->get($url)->assertNotFound(); + $this->actingAs($commit->user)->get($url)->assertNotFound(); + $this->actingAs(User::factory()->create(['username' => 'moderator-demo']))->get($url) + ->assertOk()->assertHeader('Content-Type', 'image/webp')->assertHeader('Cache-Control', 'no-store, private'); + $this->get(route('commit-report-image.show', ['report' => $report, 'image' => 1]))->assertNotFound(); + $this->get(route('reported-commit.index'))->assertInertia(fn ($page) => $page + ->where('reports.data.0.attachments.0.url', $url) + ->where('reports.data.0.attachments.0.description', 'Evidence screenshot') + ->missing('reports.data.0.images')); + + $this->actingAs($reporter)->post(route('commit-report.store', $commit), [ + 'reason' => 'spam', 'images' => [UploadedFile::fake()->image('duplicate.png')], + ])->assertRedirect(); + expect(Storage::disk()->files('report-images'))->toHaveCount(1); + expect($report->refresh()->images)->toHaveCount(1); +}); + +it('rejects invalid report images without storing a report', function (string $invalid): void { + Storage::fake(); + $images = match ($invalid) { + 'type' => [UploadedFile::fake()->create('script.svg', 1, 'image/svg+xml')], + 'size' => [UploadedFile::fake()->image('large.png')->size(10241)], + 'count' => array_map(fn () => UploadedFile::fake()->image('image.png'), range(1, 4)), + }; + $this->actingAs(User::factory()->create())->postJson(route('commit-report.store', Commit::factory()->create()), [ + 'reason' => 'other', 'images' => $images, + ])->assertUnprocessable(); + expect(CommitReport::query()->count())->toBe(0); + expect(Storage::disk()->allFiles())->toBeEmpty(); +})->with(['type', 'size', 'count']); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 1165777b7..c08a018fc 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Actions\CreateCommitReport; use App\Actions\CreateCommitTranslation; use App\Actions\DeleteUser; use App\Ai\Agents\CommitTranslator; @@ -9,6 +10,7 @@ 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\MessageVoice; @@ -20,9 +22,11 @@ use App\Models\UserTimeout; use App\Services\CommitTranslations; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Storage; it('requires authentication to download personal data', function (): void { $this->get(route('export'))->assertRedirectToRoute('home'); @@ -128,6 +132,7 @@ 'blocking' => [], 'dismissed_suggestions' => [], 'messages' => [], + 'reports' => [], 'remote_mutes' => [], 'remote_pins' => [], 'notifications' => [], @@ -559,3 +564,19 @@ ['to' => 'org: Release crew', 'pinned_at' => now()->toIso8601String()], ]); }); + +it('exports only reports and private attachment references submitted by the user', function (): void { + Storage::fake(); + $user = User::factory()->create(); + $commit = Commit::factory()->create(); + resolve(CreateCommitReport::class)->handle($user, $commit, 'other', 'My evidence', [UploadedFile::fake()->image('evidence.png')], ['Private screenshot']); + $report = CommitReport::query()->sole(); + CommitReport::factory()->create(['commit_id' => Commit::factory()->for($user)->create()->id, 'details' => 'Someone else reported me']); + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + expect($data['reports'])->toHaveCount(1) + ->and($data['reports'][0]['details'])->toBe('My evidence') + ->and($data['reports'][0]['attachments'][0]['description'])->toBe('Private screenshot') + ->and($response->streamedContent())->not->toContain($report->images[0]['path'], 'Someone else reported me'); + $this->get($data['reports'][0]['attachments'][0]['url'])->assertOk(); +}); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index d5d62c6cf..82d3f191f 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use App\Actions\CreateCommitReport; use App\Actions\CreateCommitTranslation; use App\Actions\DeleteUser; use App\Actions\UpdateCommitHotScores; use App\Ai\Agents\CommitTranslator; use App\Models\Commit; use App\Models\CommitImage; +use App\Models\CommitReport; use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\Device; @@ -21,6 +23,7 @@ use App\Models\User; use App\Services\CommitTranslations; use App\Services\TheAlgorithm; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -343,3 +346,16 @@ ->and(Storage::disk()->allFiles())->toBe([$other->path]) ->and(ConversationParticipant::query()->whereNotNull('muted_until_at')->pluck('user_id')->all())->toBe([$former->id]); }); + +it('erases private report attachments when the reporter or reported author is deleted', function (bool $deleteReporter): void { + Storage::fake(); + $reporter = User::factory()->create(); + $commit = Commit::factory()->create(); + resolve(CreateCommitReport::class)->handle($reporter, $commit, 'other', null, [UploadedFile::fake()->image('evidence.png')]); + $report = CommitReport::query()->sole(); + $path = $report->images[0]['path']; + Storage::disk()->assertExists($path); + resolve(DeleteUser::class)->handle($deleteReporter ? $reporter : $commit->user); + Storage::disk()->assertMissing($path); + $this->assertModelMissing($report); +})->with([true, false]); From 27706d599737184b810e700778395e61e8883051 Mon Sep 17 00:00:00 2001 From: Sheraz Manzoor Date: Sat, 24 Oct 2026 14:34:46 +0500 Subject: [PATCH 02/11] feat: attach images from commit report dialogs --- KNOWLEDGE_BASE.md | 4 +- .../js/components/commit-report-button.tsx | 68 ++++++++++++++++--- resources/js/pages/moderation/reports.tsx | 27 ++++++++ 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index af94f8a08..747a85cc1 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -375,7 +375,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). @@ -497,7 +497,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**. diff --git a/resources/js/components/commit-report-button.tsx b/resources/js/components/commit-report-button.tsx index e2567a628..84a8f9a3d 100644 --- a/resources/js/components/commit-report-button.tsx +++ b/resources/js/components/commit-report-button.tsx @@ -3,6 +3,12 @@ import { Flag } from 'lucide-react'; import type { ReactNode } from 'react'; import { useId, useState } from 'react'; import { store } from '@/actions/App/Http/Controllers/CommitReportController'; +import { + ImageDropOverlay, + ImagePickerButton, + ImagePickerPreviews, + imageErrorFrom, +} from '@/components/commit-image-picker'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { @@ -21,6 +27,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { useCommitNotFound } from '@/hooks/use-deleted-resources'; +import { useImageAttachments } from '@/hooks/use-image-attachments'; import type { Commit } from '@/types'; const reasons = [ @@ -81,7 +88,24 @@ export function CommitReportDialog({ const [failure, setFailure] = useState(''); const commitNotFound = useCommitNotFound(); const id = useId(); - const form = useForm({ reason: '', details: '' }); + const form = useForm({ + reason: '', + details: '', + images: [] as File[], + image_descriptions: [] as string[], + }); + const attachments = useImageAttachments( + form.data.images, + form.data.image_descriptions, + (images, descriptions) => { + form.setData((data) => ({ + ...data, + images, + image_descriptions: descriptions, + })); + }, + `report:${commit.id}:${id}`, + ); return ( {children} - + Report commit - Your report is private. Only moderators can review it. - Reporting does not hide this commit. + Your report is private. Attachments are visible only to + you and moderators. Reporting does not hide this commit.

{commit.body}

{ event.preventDefault(); + if (form.processing || attachments.isReading()) return; setFailure(''); form.submit(store(commit), { preserveScroll: true, onSuccess: () => { onOpenChange(false); + attachments.clear(); form.reset(); }, onHttpException: (response) => { @@ -192,12 +219,13 @@ export function CommitReportDialog({ onChange={(event) => form.setData('details', event.target.value) } + onPaste={attachments.onPaste} maxLength={500} rows={3} disabled={form.processing} aria-invalid={!!form.errors.details} aria-describedby={`${id}-details-hint ${id}-details-error`} - className="w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="w-full resize-y rounded-md border bg-background px-3 py-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-sm" />

+ {attachments.isDraggingFiles && } +

+ + + +
{failure && (

{failure}

)} - + diff --git a/resources/js/pages/moderation/reports.tsx b/resources/js/pages/moderation/reports.tsx index dc06f0a62..b7f21f967 100644 --- a/resources/js/pages/moderation/reports.tsx +++ b/resources/js/pages/moderation/reports.tsx @@ -17,6 +17,12 @@ type Report = { id: string; reason: string; details: string | null; + attachments: { + url: string; + description: string | null; + width: number; + height: number; + }[]; created_at: string; commit: Commit; }; @@ -111,6 +117,27 @@ export default function Reports({ {report.details}

)} + {report.attachments?.map((image) => ( + + { + + ))}

Reported{' '}