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
9 changes: 8 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ This file documents the platform's features, stack, and stories. Feature entries
- [Orgs: group chats](#orgs-group-chats)
- [Muting and blocking](#muting-and-blocking)
- [Reporting commits](#reporting-commits)
- [Reporting messages and people](#reporting-messages-and-people)
- [Appearance, color, and timezone](#appearance-color-and-timezone)
- [Installing /dev/null as an app](#installing-devnull-as-an-app)
- [Activity graphs and README embeds](#activity-graphs-and-readme-embeds)
Expand Down Expand Up @@ -498,6 +499,12 @@ Report another person's visible, approved commit or reply privately from its con

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).

### Reporting messages and people

Through the API used by the mobile app, you can also privately report a Remotes message you can see, in a direct conversation or an organization's thread, or another person's account. Reports take the same reasons as commit reports and up to **500 characters** of optional detail, without images. You can't report your own messages or yourself. You can report a particular message or person once, and file up to five of these reports a minute. Reporting does not hide the message or affect the account by itself, and the person reported is not told who reported them. Moderators review these reports privately.

Sources: [message report validation](app/Http/Requests/CreateApiMessageReportRequest.php), [user report validation](app/Http/Requests/CreateApiUserReportRequest.php).

### Appearance, color, and timezone

Settings → Appearance offers:
Expand Down Expand Up @@ -656,7 +663,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, 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.
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, on commits, messages and people, include their reason and details, and commit reports their 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
18 changes: 17 additions & 1 deletion app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
use App\Models\ConversationParticipant;
use App\Models\Device;
use App\Models\Message;
use App\Models\MessageReport;
use App\Models\Poll;
use App\Models\PollOption;
use App\Models\PushSubscription;
use App\Models\User;
use App\Models\UserReport;
use App\Models\UserTimeout;
use App\Queries\CommitBuilder;
use App\Services\CommitTranslations;
Expand Down Expand Up @@ -55,6 +57,8 @@
* pending_follows: list<string>,
* messages: Generator<int, ExportMessage>,
* reports: Traversable<int, array<string, mixed>>,
* message_reports: Traversable<int, array{message_id: int, reason: string, details: string|null, created_at: string}>,
* user_reports: Traversable<int, array{user_id: string, reason: string, details: string|null, created_at: string}>,
* remote_pins: array<int, array{to: string|null, pinned_at: string|null}>,
* remote_mentions: Generator<int, array{to: string|null, created_at: string}>,
* remote_mutes: array<int, array{to: string|null, muted_until_at: string|null}>,
Expand All @@ -76,7 +80,7 @@
public function handle(User $user): array
{
return [
'schema_version' => 8,
'schema_version' => 9,
'exported_at' => now()->toIso8601String(),
'profile' => [
'github_id' => $user->github_id,
Expand Down Expand Up @@ -139,6 +143,18 @@ public function handle(User $user): array
'attachments' => $report->toArray()['attachments'],
'created_at' => $report->created_at->toIso8601String(),
])->getIterator(),
'message_reports' => MessageReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (MessageReport $report): array => [
'message_id' => $report->message_id,
'reason' => $report->reason,
'details' => $report->details,
'created_at' => $report->created_at->toIso8601String(),
])->getIterator(),
'user_reports' => UserReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (UserReport $report): array => [
'user_id' => $report->user_id,
'reason' => $report->reason,
'details' => $report->details,
'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
40 changes: 40 additions & 0 deletions app/Actions/CreateMessageReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\UserUpdate;
use App\Events\UsersUpdated;
use App\Models\Message;
use App\Models\MessageReport;
use App\Models\User;
use App\Queries\AdminsQuery;
use Illuminate\Support\Facades\DB;

final readonly class CreateMessageReport
{
public function __construct(private AdminsQuery $admins)
{
//
}

public function handle(User $user, Message $message, string $reason, ?string $details): void
{
DB::transaction(function () use ($user, $message, $reason, $details): 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();
Message::query()->whereKey($message->id)->sharedLock()->firstOrFail();

$report = MessageReport::query()->createOrFirst([
'message_id' => $message->id,
'reporter_id' => $user->id,
], [
'reason' => $reason,
'details' => $details,
]);

event(new UsersUpdated($this->admins->ids(), UserUpdate::Moderation, ['message_reports' => [$report->id]]));
});
}
}
39 changes: 39 additions & 0 deletions app/Actions/CreateUserReport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\UserUpdate;
use App\Events\UsersUpdated;
use App\Models\User;
use App\Models\UserReport;
use App\Queries\AdminsQuery;
use Illuminate\Support\Facades\DB;

final readonly class CreateUserReport
{
public function __construct(private AdminsQuery $admins)
{
//
}

public function handle(User $user, User $reported, string $reason, ?string $details): void
{
DB::transaction(function () use ($user, $reported, $reason, $details): void {
// Match account deletion's lock order and recheck accounts that may have been deleted since the request began.
User::query()->whereKey($user->id)->sharedLock()->firstOrFail();
User::query()->whereKey($reported->id)->sharedLock()->firstOrFail();

$report = UserReport::query()->createOrFirst([
'user_id' => $reported->id,
'reporter_id' => $user->id,
], [
'reason' => $reason,
'details' => $details,
]);

event(new UsersUpdated($this->admins->ids(), UserUpdate::Moderation, ['user_reports' => [$report->id]]));
});
}
}
25 changes: 25 additions & 0 deletions app/Actions/UpdateMessageReportDismissedAt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\UserUpdate;
use App\Events\UsersUpdated;
use App\Models\MessageReport;
use App\Queries\AdminsQuery;

final readonly class UpdateMessageReportDismissedAt
{
public function __construct(private AdminsQuery $admins)
{
//
}

public function handle(MessageReport $report): void
{
MessageReport::query()->whereKey($report->id)->whereNull('dismissed_at')->update(['dismissed_at' => now()]);

event(new UsersUpdated($this->admins->ids(), UserUpdate::Moderation, ['message_reports' => [$report->id]]));
}
}
25 changes: 25 additions & 0 deletions app/Actions/UpdateUserReportDismissedAt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\UserUpdate;
use App\Events\UsersUpdated;
use App\Models\UserReport;
use App\Queries\AdminsQuery;

final readonly class UpdateUserReportDismissedAt
{
public function __construct(private AdminsQuery $admins)
{
//
}

public function handle(UserReport $report): void
{
UserReport::query()->whereKey($report->id)->whereNull('dismissed_at')->update(['dismissed_at' => now()]);

event(new UsersUpdated($this->admins->ids(), UserUpdate::Moderation, ['user_reports' => [$report->id]]));
}
}
4 changes: 3 additions & 1 deletion app/Enums/ApiErrorCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ enum ApiErrorCode: string
case TimedOut = 'timed_out';
case Banned = 'banned';
case CommitRulesRequired = 'commit_rules_required';
case OwnMessage = 'own_message';
case OwnProfile = 'own_profile';

public function status(): int
{
return match ($this) {
self::InvalidGrant => 400,
self::InvalidSignature, self::DeviceNotApproved => 401,
self::UserBlocked, self::UserUnavailable, self::OwnCommit, self::TimedOut, self::Banned, self::CommitRulesRequired => 403,
self::UserBlocked, self::UserUnavailable, self::OwnCommit, self::TimedOut, self::Banned, self::CommitRulesRequired, self::OwnMessage, self::OwnProfile => 403,
self::CommitUnavailable => 404,
self::PasteisExhausted => 422,
self::RateLimited => 429,
Expand Down
25 changes: 25 additions & 0 deletions app/Http/Controllers/Api/MessageReportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\CreateMessageReport;
use App\Http\Requests\CreateApiMessageReportRequest;
use App\Models\Message;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Response;

final readonly class MessageReportController
{
/**
* Reports are private, so there is nothing to answer with. Reporting again keeps the first report.
*/
public function store(CreateApiMessageReportRequest $request, #[CurrentUser] User $user, Message $message, CreateMessageReport $action): Response
{
$action->handle($user, $message, $request->string('reason')->value(), $request->filled('details') ? $request->string('details')->value() : null);

return response()->noContent();
}
}
24 changes: 24 additions & 0 deletions app/Http/Controllers/Api/UserReportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\CreateUserReport;
use App\Http\Requests\CreateApiUserReportRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Response;

final readonly class UserReportController
{
/**
* Reports are private, so there is nothing to answer with. Reporting again keeps the first report.
*/
public function store(CreateApiUserReportRequest $request, #[CurrentUser] User $currentUser, User $user, CreateUserReport $action): Response
{
$action->handle($currentUser, $user, $request->string('reason')->value(), $request->filled('details') ? $request->string('details')->value() : null);

return response()->noContent();
}
}
20 changes: 20 additions & 0 deletions app/Http/Controllers/MessageReportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateMessageReportDismissedAt;
use App\Http\Requests\DeleteMessageReportRequest;
use App\Models\MessageReport;
use Illuminate\Http\RedirectResponse;

final readonly class MessageReportController
{
public function destroy(DeleteMessageReportRequest $request, MessageReport $report, UpdateMessageReportDismissedAt $action): RedirectResponse
{
$action->handle($report);

return back();
}
}
26 changes: 26 additions & 0 deletions app/Http/Controllers/ReportedUserController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\ListReportedUsersRequest;
use App\Models\CommitReport;
use App\Queries\ReportedUsersQuery;
use Inertia\Inertia;
use Inertia\Response;

final readonly class ReportedUserController
{
/**
* Reports on messages in remotes and on people, which moderators act on from the profile.
*/
public function index(ListReportedUsersRequest $request, ReportedUsersQuery $query): Response
{
return Inertia::render('moderation/people-reports', [
'messageReports' => $query->messages(...),
'userReports' => $query->users(...),
'reasons' => CommitReport::REASONS,
]);
}
}
20 changes: 20 additions & 0 deletions app/Http/Controllers/UserReportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateUserReportDismissedAt;
use App\Http\Requests\DeleteUserReportRequest;
use App\Models\UserReport;
use Illuminate\Http\RedirectResponse;

final readonly class UserReportController
{
public function destroy(DeleteUserReportRequest $request, UserReport $report, UpdateUserReportDismissedAt $action): RedirectResponse
{
$action->handle($report);

return back();
}
}
Loading