You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pixelfed/app/Http/Controllers/DirectConversationControlle...

417 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Exceptions\DirectMessageException;
use App\Models\DmConversation;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Models\Profile;
use App\Services\DirectMessagePayloadService;
use App\Services\DirectMessageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DirectConversationController extends Controller
{
public function __construct(
protected DirectMessageService $service,
protected DirectMessagePayloadService $payloads
) {
$this->middleware('auth');
}
/**
* GET /api/v1.1/direct/conversations
*/
public function index(Request $request): JsonResponse
{
$this->authorizeRequest($request, 'read');
$this->validate($request, [
'filter' => 'sometimes|string|in:primary,requests,hidden',
'limit' => 'sometimes|integer|min:1|max:40',
'cursor' => 'sometimes|nullable|string',
]);
$pid = $request->user()->profile_id;
$filter = $request->input('filter', 'primary');
$page = $this->inboxQuery($pid, $filter)
->with('conversation')
->cursorPaginate((int) $request->input('limit', 20));
return response()->json([
'data' => $this->payloads->conversations(collect($page->items()), $pid),
'meta' => [
'filter' => $filter,
'next_cursor' => $page->nextCursor()?->encode(),
'prev_cursor' => $page->previousCursor()?->encode(),
],
]);
}
/**
* GET /api/v1.1/direct/unread_count
*/
public function unreadCount(Request $request): JsonResponse
{
$this->authorizeRequest($request, 'read');
$pid = $request->user()->profile_id;
return response()->json([
'primary' => (int) $this->inboxQuery($pid, 'primary')
->whereNull('muted_at')
->where('unread_count', '>', 0)
->count(),
'requests' => (int) $this->inboxQuery($pid, 'requests')->count(),
]);
}
/**
* POST /api/v1.1/direct/conversations
*
* Find the conversation with these people, or start it. One recipient is
* a 1:1 chat, several are a group.
*/
public function store(Request $request): JsonResponse
{
$this->authorizeRequest($request, 'write');
$this->validate($request, [
'recipient_ids' => 'required|array|min:1|max:'.max(1, (int) config('dm.groups.max_participants') - 1),
'recipient_ids.*' => 'required|integer|min:1|distinct',
]);
$user = $request->user();
$sender = $user->profile;
$ids = collect($request->input('recipient_ids'))->map(fn ($id) => (int) $id)->reject(fn ($id) => $id === $sender->id);
$recipients = Profile::whereIn('id', $ids)->whereNull('status')->get();
if ($recipients->isEmpty() || $recipients->count() !== $ids->count()) {
throw new DirectMessageException('One or more recipients could not be found.', 404);
}
$hash = DmConversation::participantsHash($recipients->pluck('id')->push($sender->id)->all());
$exists = DmConversation::where('participants_hash', $hash)->exists();
if (! $exists) {
if (! $this->service->canInitiateConversation($user)) {
throw new DirectMessageException('You need to wait a bit before you can DM another account', 400);
}
foreach ($recipients as $recipient) {
if ($recipients->count() === 1 && ! $this->service->canMessage($sender, $recipient)) {
throw new DirectMessageException('You cannot message this account.', 403);
}
// In a group, someone who blocks the sender is still added and
// simply never sees their messages. The sender blocking one
// of their own picks is a mistake worth pointing out.
if ($recipients->count() > 1 && $this->service->isBlockedBy($sender, $recipient)) {
throw new DirectMessageException('You have blocked one of these accounts.', 422);
}
if ($recipient->domain !== null && ! config('federation.activitypub.enabled')) {
throw new DirectMessageException('You cannot message this account.', 403);
}
}
}
$conversation = $this->service->findOrCreateConversation($sender, $recipients);
$participant = $this->service->participant($conversation, $sender->id);
if ($participant->hasLeft()) {
$participant->state = DmConversationParticipant::STATE_ACTIVE;
$participant->save();
}
return response()->json(
$this->payloads->conversation($conversation, $participant, null, $this->lastVisibleMessage($conversation, $sender->id), $sender->id),
$exists ? 200 : 201
);
}
/**
* GET /api/v1.1/direct/conversations/{id}
*/
public function show(Request $request, $id): JsonResponse
{
$this->authorizeRequest($request, 'read');
[$conversation, $participant] = $this->resolve($request, $id);
$pid = $request->user()->profile_id;
return response()->json(
$this->payloads->conversation($conversation, $participant, null, $this->lastVisibleMessage($conversation, $pid), $pid)
);
}
/**
* GET /api/v1.1/direct/conversations/{id}/messages
*
* Newest first. `max_id` pages back in time, `min_id` returns what
* arrived since, which is what a client polls with.
*/
public function messages(Request $request, $id): JsonResponse
{
$this->authorizeRequest($request, 'read');
$this->validate($request, [
'limit' => 'sometimes|integer|min:1|max:50',
'max_id' => 'sometimes|integer|min:1',
'min_id' => 'sometimes|integer|min:1',
]);
[$conversation] = $this->resolve($request, $id);
$pid = $request->user()->profile_id;
$limit = (int) $request->input('limit', 20);
$query = DmMessage::with('media')
->where('conversation_id', $conversation->id)
->whereNotIn('profile_id', $this->payloads->blockedIds($pid) ?: [0]);
if ($request->filled('min_id')) {
$messages = $query->where('id', '>', $request->input('min_id'))
->orderBy('id')
->limit($limit + 1)
->get();
$hasMore = $messages->count() > $limit;
$messages = $messages->take($limit)->reverse()->values();
} else {
if ($request->filled('max_id')) {
$query->where('id', '<', $request->input('max_id'));
}
$messages = $query->orderByDesc('id')->limit($limit + 1)->get();
$hasMore = $messages->count() > $limit;
$messages = $messages->take($limit)->values();
}
return response()->json([
'data' => $this->payloads->messages($messages, $pid),
'meta' => [
'has_more' => $hasMore,
'newest_id' => $messages->isNotEmpty() ? (string) $messages->first()->id : null,
'oldest_id' => $messages->isNotEmpty() ? (string) $messages->last()->id : null,
],
]);
}
/**
* POST /api/v1.1/direct/conversations/{id}/messages
*
* Text, media, or both in a single message. Media is uploaded first
* through /api/v2/media and referenced here by id.
*/
public function send(Request $request, $id): JsonResponse
{
$this->authorizeRequest($request, 'write');
$this->validate($request, [
'message' => 'nullable|required_without:media_ids|string|max:'.(int) config('dm.max_message_length'),
'media_ids' => 'nullable|required_without:message|array|max:'.(int) config('dm.max_media'),
'media_ids.*' => 'integer|min:1|distinct',
'in_reply_to_id' => 'sometimes|nullable|integer|min:1',
'sensitive' => 'sometimes|boolean',
'type' => 'sometimes|string|in:text,emoji',
]);
[$conversation] = $this->resolve($request, $id);
$user = $request->user();
$message = $this->service->sendMessage($conversation, $user->profile, [
'body' => $request->input('message'),
'type' => $request->input('type'),
'media' => $this->service->attachableMedia($user, $request->input('media_ids', []) ?? []),
'in_reply_to_id' => $request->input('in_reply_to_id'),
'is_sensitive' => $request->boolean('sensitive'),
]);
return response()->json($this->payloads->message($message->load('media'), $user->profile_id), 201);
}
/**
* DELETE /api/v1.1/direct/conversations/{id}/messages/{messageId}
*/
public function deleteMessage(Request $request, $id, $messageId): JsonResponse
{
$this->authorizeRequest($request, 'write');
[$conversation] = $this->resolve($request, $id);
$message = DmMessage::where('conversation_id', $conversation->id)
->where('profile_id', $request->user()->profile_id)
->findOrFail($messageId);
$this->service->deleteMessage($message);
return response()->json(['deleted' => true]);
}
/**
* POST /api/v1.1/direct/conversations/{id}/read
*/
public function read(Request $request, $id): JsonResponse
{
$this->authorizeRequest($request, 'write');
$this->validate($request, [
'message_id' => 'sometimes|nullable|integer|min:1',
]);
[, $participant] = $this->resolve($request, $id);
$this->service->markRead($participant, $request->filled('message_id') ? (int) $request->input('message_id') : null);
return $this->state($participant);
}
/**
* POST /api/v1.1/direct/conversations/{id}/accept
*/
public function accept(Request $request, $id): JsonResponse
{
$this->authorizeRequest($request, 'write');
[, $participant] = $this->resolve($request, $id);
$this->service->accept($participant);
return $this->state($participant);
}
public function mute(Request $request, $id): JsonResponse
{
return $this->toggle($request, $id, fn ($participant) => $this->service->setMuted($participant, true));
}
public function unmute(Request $request, $id): JsonResponse
{
return $this->toggle($request, $id, fn ($participant) => $this->service->setMuted($participant, false));
}
public function hide(Request $request, $id): JsonResponse
{
return $this->toggle($request, $id, fn ($participant) => $this->service->setHidden($participant, true));
}
public function unhide(Request $request, $id): JsonResponse
{
return $this->toggle($request, $id, fn ($participant) => $this->service->setHidden($participant, false));
}
/**
* POST /api/v1.1/direct/conversations/{id}/leave
*/
public function leave(Request $request, $id): JsonResponse
{
$this->authorizeRequest($request, 'write');
[$conversation, $participant] = $this->resolve($request, $id);
$this->service->leave($conversation, $participant);
return $this->state($participant);
}
/*
|--------------------------------------------------------------------------
| Internals
|--------------------------------------------------------------------------
*/
protected function toggle(Request $request, $id, callable $action): JsonResponse
{
$this->authorizeRequest($request, 'write');
[, $participant] = $this->resolve($request, $id);
$action($participant);
return $this->state($participant);
}
protected function state(DmConversationParticipant $participant): JsonResponse
{
return response()->json([
'id' => (string) $participant->conversation_id,
'state' => $participant->state,
'unread_count' => (int) $participant->unread_count,
'muted' => $participant->muted_at !== null,
'hidden' => $participant->hidden_at !== null,
'last_read_message_id' => $participant->last_read_message_id ? (string) $participant->last_read_message_id : null,
]);
}
/**
* OAuth tokens need the matching scope. Session and cookie auth have no
* token and are allowed through, like the rest of the web API.
*/
protected function authorizeRequest(Request $request, string $scope): void
{
$user = $request->user();
abort_if(! $user, 403);
if ($user->token() && ! $user->tokenCan($scope)) {
abort(403, 'Missing required scope: '.$scope);
}
abort_if(! $this->service->canUseDirectMessages($user), 403, 'Invalid permissions for this action');
}
/**
* @return array{0: DmConversation, 1: DmConversationParticipant}
*/
protected function resolve(Request $request, $id): array
{
abort_unless(is_numeric($id), 404);
$found = $this->service->conversationFor($id, $request->user()->profile_id);
abort_if(! $found, 404);
return $found;
}
/**
* The viewer's conversations for one tab of the inbox. A conversation
* shows up once something in it is visible to the viewer.
*/
protected function inboxQuery(int $profileId, string $filter)
{
$query = DmConversationParticipant::where('profile_id', $profileId)
->whereNotNull('last_activity_at');
match ($filter) {
'requests' => $query->where('state', DmConversationParticipant::STATE_REQUEST)->whereNull('hidden_at'),
'hidden' => $query->whereIn('state', [DmConversationParticipant::STATE_ACTIVE, DmConversationParticipant::STATE_REQUEST])->whereNotNull('hidden_at'),
default => $query->where('state', DmConversationParticipant::STATE_ACTIVE)->whereNull('hidden_at'),
};
return $query->orderByDesc('last_activity_at')->orderByDesc('id');
}
protected function lastVisibleMessage(DmConversation $conversation, int $viewerId): ?DmMessage
{
if (! $conversation->last_message_id) {
return null;
}
$message = DmMessage::with('media')->find($conversation->last_message_id);
if ($message && in_array((int) $message->profile_id, $this->payloads->blockedIds($viewerId), true)) {
return null;
}
return $message;
}
}