diff --git a/app/Console/Commands/DirectMessage/BackfillConversations.php b/app/Console/Commands/DirectMessage/BackfillConversations.php new file mode 100644 index 000000000..e9b5f31c3 --- /dev/null +++ b/app/Console/Commands/DirectMessage/BackfillConversations.php @@ -0,0 +1,314 @@ +count(); + + if ($total === 0) { + $this->info('No legacy direct messages to convert.'); + + return self::SUCCESS; + } + + $start = $this->option('full') ? 0 : (int) DB::table('dm_messages')->max('legacy_dm_id'); + $remaining = DB::table('direct_messages')->where('id', '>', $start)->count(); + + if ($remaining === 0) { + $this->info('Legacy direct messages are already converted.'); + $this->convertMutes(); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm("Convert {$remaining} legacy direct messages?", true)) { + return self::SUCCESS; + } + + $chunk = max(50, (int) $this->option('chunk')); + $bar = $this->output->createProgressBar($remaining); + $bar->start(); + + DB::table('direct_messages') + ->where('id', '>', $start) + ->orderBy('id') + ->chunkById($chunk, function (Collection $rows) use ($bar) { + $this->convert($rows); + $bar->advance($rows->count()); + }); + + $bar->finish(); + $this->line(''); + + $this->convertMutes(); + + $this->info('Done.'); + + return self::SUCCESS; + } + + protected function convert(Collection $rows): void + { + $rows = $rows->filter(fn ($row) => $row->from_id && $row->to_id && $row->from_id != $row->to_id); + + if ($rows->isEmpty()) { + return; + } + + $done = DB::table('dm_messages')->whereIn('legacy_dm_id', $rows->pluck('id'))->pluck('legacy_dm_id')->flip(); + $taken = DB::table('dm_messages')->whereIn('id', $rows->pluck('status_id'))->pluck('id')->flip(); + + $statuses = DB::table('statuses') + ->whereIn('id', $rows->pluck('status_id')) + ->whereNull('deleted_at') + ->get(['id', 'profile_id', 'caption', 'is_nsfw', 'uri', 'object_url', 'url', 'created_at']) + ->keyBy('id'); + + $profiles = DB::table('profiles') + ->whereIn('id', $rows->pluck('from_id')->merge($rows->pluck('to_id'))->unique()) + ->get(['id', 'username', 'domain']) + ->keyBy('id'); + + $media = DB::table('media') + ->whereIn('status_id', $statuses->keys()) + ->whereNull('deleted_at') + ->orderBy('order') + ->get(['id', 'status_id']) + ->groupBy('status_id'); + + $touched = []; + + foreach ($rows as $row) { + $status = $statuses->get($row->status_id); + $from = $profiles->get($row->from_id); + $to = $profiles->get($row->to_id); + + if (! $status || ! $from || ! $to || $done->has($row->id) || $taken->has($row->status_id)) { + continue; + } + + $conversationId = $this->conversation($row); + $touched[$conversationId] = [(int) $row->from_id, (int) $row->to_id]; + + $body = $status->caption; + + if ($body !== null && $from->domain !== null) { + $body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + $uri = $from->domain !== null + ? ($status->uri ?: ($status->object_url ?: $status->url)) + : url(config('app.url').'/p/'.$from->username.'/'.$status->id); + + $inserted = DB::table('dm_messages')->insertOrIgnore([ + 'id' => $status->id, + 'conversation_id' => $conversationId, + 'profile_id' => $row->from_id, + 'type' => $row->type ?: DmMessage::TYPE_TEXT, + 'body' => $body === '' ? null : $body, + 'meta' => $this->meta($row->meta), + 'is_sensitive' => (bool) $status->is_nsfw, + 'ap_object_uri' => $uri, + 'ap_object_hash' => $uri ? DmMessage::hashUri($uri) : null, + 'status_id' => $status->id, + 'legacy_dm_id' => $row->id, + 'created_at' => $row->created_at ?? $status->created_at, + 'updated_at' => $row->updated_at ?? $row->created_at ?? $status->created_at, + ]); + + if (! $inserted) { + continue; + } + + $taken->put($status->id, true); + + foreach ($media->get($status->id, collect())->values() as $position => $item) { + DB::table('dm_message_media')->insertOrIgnore([ + 'message_id' => $status->id, + 'media_id' => $item->id, + 'position' => $position, + ]); + } + } + + foreach ($touched as $conversationId => $pair) { + $this->refresh($conversationId, $pair); + } + } + + /** + * The conversation for a legacy row, created on first sight. A legacy + * message that was filtered (is_hidden) means its recipient had not + * accepted the sender yet, which is what a request is now. + */ + protected function conversation(object $row): int + { + $hash = DmConversation::dmHash((int) $row->from_id, (int) $row->to_id); + $id = DB::table('dm_conversations')->where('participants_hash', $hash)->value('id'); + + if (! $id) { + $id = SnowflakeService::next(); + $now = now(); + + DB::table('dm_conversations')->insertOrIgnore([ + 'id' => $id, + 'type' => DmConversation::TYPE_DM, + 'participants_hash' => $hash, + 'context_uri' => DmConversation::localContextUri($id), + 'created_by_profile_id' => $row->from_id, + 'created_at' => $row->created_at ?? $now, + 'updated_at' => $now, + ]); + + $id = (int) DB::table('dm_conversations')->where('participants_hash', $hash)->value('id'); + + foreach ([(int) $row->from_id, (int) $row->to_id] as $profileId) { + DB::table('dm_conversation_participants')->insertOrIgnore([ + 'conversation_id' => $id, + 'profile_id' => $profileId, + 'state' => $profileId === (int) $row->to_id && $row->is_hidden + ? DmConversationParticipant::STATE_REQUEST + : DmConversationParticipant::STATE_ACTIVE, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + return $id; + } + + // Writing back, or getting a message through unfiltered, settles a request + DB::table('dm_conversation_participants') + ->where('conversation_id', $id) + ->where('state', DmConversationParticipant::STATE_REQUEST) + ->where(function ($query) use ($row) { + $query->where('profile_id', $row->from_id); + + if (! $row->is_hidden) { + $query->orWhere('profile_id', $row->to_id); + } + }) + ->update(['state' => DmConversationParticipant::STATE_ACTIVE]); + + return (int) $id; + } + + /** + * Bring the conversation pointers and both read states in line with what + * has been converted so far. + * + * @param array{0: int, 1: int} $pair + */ + protected function refresh(int $conversationId, array $pair): void + { + $last = DB::table('dm_messages') + ->where('conversation_id', $conversationId) + ->whereNull('deleted_at') + ->orderByDesc('id') + ->first(['id', 'created_at']); + + if (! $last) { + return; + } + + DB::table('dm_conversations') + ->where('id', $conversationId) + ->where(function ($query) use ($last) { + $query->whereNull('last_message_id')->orWhere('last_message_id', '<', $last->id); + }) + ->update(['last_message_id' => $last->id, 'last_message_at' => $last->created_at]); + + foreach ([[$pair[0], $pair[1]], [$pair[1], $pair[0]]] as [$me, $other]) { + $unread = DB::table('direct_messages') + ->where('to_id', $me) + ->where('from_id', $other) + ->whereNull('read_at') + ->whereIn('status_id', DB::table('dm_messages')->where('conversation_id', $conversationId)->select('id')) + ->count(); + + $lastRead = $unread === 0 + ? $last->id + : DB::table('direct_messages') + ->where('to_id', $me) + ->where('from_id', $other) + ->whereNotNull('read_at') + ->max('status_id'); + + DB::table('dm_conversation_participants') + ->where('conversation_id', $conversationId) + ->where('profile_id', $me) + ->update([ + 'unread_count' => $unread, + 'last_read_message_id' => $lastRead, + 'last_activity_at' => $last->created_at, + ]); + } + } + + /** + * Muting a thread used to be a `dm.mute` user filter. + */ + protected function convertMutes(): void + { + DB::table('user_filters') + ->where('filter_type', 'dm.mute') + ->orderBy('id') + ->chunkById(500, function (Collection $filters) { + foreach ($filters as $filter) { + $id = DB::table('dm_conversations') + ->where('participants_hash', DmConversation::dmHash((int) $filter->user_id, (int) $filter->filterable_id)) + ->value('id'); + + if (! $id) { + continue; + } + + DB::table('dm_conversation_participants') + ->where('conversation_id', $id) + ->where('profile_id', $filter->user_id) + ->whereNull('muted_at') + ->update(['muted_at' => $filter->created_at ?? now()]); + } + }); + } + + protected function meta(mixed $meta): ?string + { + if ($meta === null || $meta === '') { + return null; + } + + $decoded = is_string($meta) ? json_decode($meta, true) : $meta; + + // Older rows were json encoded twice + if (is_string($decoded)) { + $decoded = json_decode($decoded, true); + } + + return is_array($decoded) ? json_encode($decoded) : null; + } +} diff --git a/app/Console/Commands/Internal/GarbageCollectorMedia.php b/app/Console/Commands/Internal/GarbageCollectorMedia.php index bc40e5c06..910de91a5 100644 --- a/app/Console/Commands/Internal/GarbageCollectorMedia.php +++ b/app/Console/Commands/Internal/GarbageCollectorMedia.php @@ -41,7 +41,9 @@ class GarbageCollectorMedia extends Command { $limit = 500; + // Direct message media has no status either, and is not garbage $gc = Media::whereNull('status_id') + ->notInDirectMessage() ->where('created_at', '<', now()->subHours(2)->toDateTimeString()) ->take($limit) ->get(); diff --git a/app/Exceptions/DirectMessageException.php b/app/Exceptions/DirectMessageException.php new file mode 100644 index 000000000..7056bbfdf --- /dev/null +++ b/app/Exceptions/DirectMessageException.php @@ -0,0 +1,37 @@ +status; + } + + public function render(): JsonResponse + { + return response()->json(['error' => $this->getMessage()], $this->status); + } + + /** + * Expected user errors, not worth a log line. + */ + public function report(): bool + { + return true; + } +} diff --git a/app/Federation/ActivityBuilders/DirectMessageActivityBuilder.php b/app/Federation/ActivityBuilders/DirectMessageActivityBuilder.php new file mode 100644 index 000000000..4fd7243c6 --- /dev/null +++ b/app/Federation/ActivityBuilders/DirectMessageActivityBuilder.php @@ -0,0 +1,167 @@ + 'http://ostatus.org#', + 'conversation' => 'ostatus:conversation', + 'sensitive' => 'as:sensitive', + 'toot' => 'http://joinmastodon.org/ns#', + 'blurhash' => 'toot:blurhash', + ], + ]; + + /** + * @param Collection $others Everyone in the conversation except the sender + * @return array + */ + public function buildCreate(DmMessage $message, DmConversation $conversation, Profile $sender, Collection $others): array + { + $note = $this->buildNote($message, $conversation, $sender, $others); + + return [ + '@context' => self::CONTEXT, + 'id' => $note['id'].'/activity', + 'type' => 'Create', + 'actor' => $note['attributedTo'], + 'published' => $note['published'], + 'to' => $note['to'], + 'cc' => [], + 'object' => $note, + ]; + } + + /** + * Three things make this thread on Mastodon and show up under + * Conversations rather than as a stray private post: + * + * - every addressee in `to` also has a Mention tag, otherwise the status + * is filed as "limited" and never reaches the DM view + * - `inReplyTo` points at the previous message, so the receiving server + * puts it in the same conversation as its parent + * - `context` and `conversation` repeat what the thread is known by, for + * servers that never saw the parent + * + * @param Collection $others + * @return array + */ + public function buildNote(DmMessage $message, DmConversation $conversation, Profile $sender, Collection $others): array + { + $recipients = $others->filter(fn (Profile $profile) => $profile->status === null)->values(); + $uri = $message->objectUri(); + + return [ + 'id' => $uri, + 'type' => 'Note', + 'summary' => null, + 'content' => DirectMessagePayloadService::renderHtml($message->body), + 'inReplyTo' => $this->inReplyTo($message), + 'published' => $message->created_at->toAtomString(), + 'url' => $uri, + 'attributedTo' => $sender->permalink(), + 'to' => $recipients->map(fn (Profile $profile) => $profile->permalink())->values()->all(), + 'cc' => [], + 'sensitive' => (bool) $message->is_sensitive, + 'context' => $conversation->contextUri(), + 'conversation' => $conversation->conversationUri(), + 'attachment' => $message->media()->get()->map(fn (Media $media) => $this->attachment($media))->values()->all(), + 'tag' => $recipients->map(fn (Profile $profile) => [ + 'type' => 'Mention', + 'href' => $profile->permalink(), + 'name' => $this->mentionName($profile), + ])->values()->all(), + ]; + } + + /** + * @param Collection $others + * @return array + */ + public function buildDelete(DmMessage $message, Profile $sender, Collection $others): array + { + $uri = $message->objectUri(); + + return [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $uri.'#delete', + 'type' => 'Delete', + 'actor' => $sender->permalink(), + 'to' => $others + ->filter(fn (Profile $profile) => $profile->status === null) + ->map(fn (Profile $profile) => $profile->permalink()) + ->values() + ->all(), + 'object' => [ + 'id' => $uri, + 'type' => 'Tombstone', + ], + ]; + } + + /** + * The message being answered, or failing that the one right before this + * one, so the thread is a chain remote servers can follow. + */ + protected function inReplyTo(DmMessage $message): ?string + { + if ($message->in_reply_to_id) { + $parent = DmMessage::find($message->in_reply_to_id); + + if ($parent) { + return $parent->objectUri(); + } + } + + $previous = DmMessage::where('conversation_id', $message->conversation_id) + ->where('id', '<', $message->id) + ->whereNotIn('type', [DmMessage::TYPE_STORY_REACT, DmMessage::TYPE_STORY_COMMENT]) + ->orderByDesc('id') + ->first(); + + return $previous?->objectUri(); + } + + /** + * @return array + */ + protected function attachment(Media $media): array + { + $attachment = [ + 'type' => $media->activityVerb(), + 'mediaType' => $media->mime === 'image/jpg' ? 'image/jpeg' : $media->mime, + 'url' => $media->url(), + 'name' => $media->caption, + ]; + + if ($media->blurhash) { + $attachment['blurhash'] = $media->blurhash; + } + + if ($media->width && $media->height) { + $attachment['width'] = (int) $media->width; + $attachment['height'] = (int) $media->height; + } + + return $attachment; + } + + protected function mentionName(Profile $profile): string + { + if ($profile->domain === null) { + return '@'.$profile->username.'@'.parse_url(config('app.url'), PHP_URL_HOST); + } + + return str_starts_with($profile->username, '@') ? $profile->username : '@'.$profile->username; + } +} diff --git a/app/Federation/Handlers/DirectMessageHandler.php b/app/Federation/Handlers/DirectMessageHandler.php new file mode 100644 index 000000000..2f6a171da --- /dev/null +++ b/app/Federation/Handlers/DirectMessageHandler.php @@ -0,0 +1,429 @@ +domain === null || $actor->status !== null) { + return null; + } + + if (! DirectMessageValidator::validate($object)) { + return null; + } + + $id = Helpers::pluckval($object['id']); + + if ($this->alreadyStored($id)) { + return null; + } + + $others = $this->resolveParticipants($object, $actor); + + if ($others === null) { + return null; + } + + $locals = $others->filter(fn (Profile $profile) => $profile->domain === null); + + // A recipient who blocks the sender never sees the message. When that + // leaves nobody here to read it there is nothing to store. + $readers = $locals->reject(fn (Profile $profile) => $this->service->isBlockedBy($profile, $actor)); + + if ($readers->isEmpty()) { + return null; + } + + $existing = DmConversation::where( + 'participants_hash', + DmConversation::participantsHash($others->pluck('id')->push($actor->id)->all()) + )->first(); + + if ($existing) { + // Someone who left the conversation is no longer reading it + $left = DmConversationParticipant::where('conversation_id', $existing->id) + ->where('state', DmConversationParticipant::STATE_LEFT) + ->pluck('profile_id') + ->all(); + + $readers = $readers->reject(fn (Profile $profile) => in_array($profile->id, $left)); + + if ($readers->isEmpty() || $this->requestLimitReached($existing, $actor, $readers)) { + return null; + } + } + + $body = self::plainText($object['content'] ?? null); + $media = $this->storeAttachments($object, $actor); + + if ($body === null && $media->isEmpty()) { + return null; + } + + try { + $conversation = $existing ?? $this->service->findOrCreateConversation($actor, $others, [ + 'context_uri' => $this->uri($object['context'] ?? null), + 'conversation_uri' => $this->uri($object['conversation'] ?? null), + ]); + } catch (DirectMessageException) { + return null; + } + + $this->service->adoptContext( + $conversation, + $this->uri($object['context'] ?? null), + $this->uri($object['conversation'] ?? null) + ); + + try { + return $this->service->storeMessage($conversation, $actor, [ + 'body' => $body, + 'media' => $media, + 'ap_object_uri' => $id, + 'in_reply_to_id' => $this->parentId($conversation, $object['inReplyTo'] ?? null), + 'is_sensitive' => (bool) ($object['sensitive'] ?? false), + ]); + } catch (Throwable $e) { + // Most likely the same delivery processed twice at once + Log::info('DirectMessageHandler: message not stored', [ + 'id' => $id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * An Update for a message $actor wrote: the text changed. Returns true + * when the object was a direct message. + */ + public function handleUpdate(array $object, Profile $actor): bool + { + $id = Helpers::pluckval($object['id'] ?? null); + + if (! is_string($id)) { + return false; + } + + $message = DmMessage::whereObjectUri($id)->first(); + + if (! $message) { + return false; + } + + if ((int) $message->profile_id !== (int) $actor->id || ! DirectMessageValidator::isDirect($object, $actor)) { + return true; + } + + if (isset($object['content']) && is_string($object['content'])) { + $body = self::plainText($object['content']); + + if ($body !== $message->body) { + $message->body = $body; + $message->edited_at = now(); + $message->save(); + } + } + + return true; + } + + /** + * A Delete for a message $actor wrote. Returns true when the object was a + * direct message, so the caller can stop looking for a status. + */ + public function handleDelete(Profile $actor, string $objectId): bool + { + $message = DmMessage::whereObjectUri($objectId)->first(); + + if (! $message) { + return false; + } + + if ((int) $message->profile_id === (int) $actor->id) { + $this->service->deleteMessage($message, false); + } + + return true; + } + + /** + * Messages from before the refactor live in the statuses table, and a + * redelivery of one of those must not come back as a new message. + */ + protected function alreadyStored(string $id): bool + { + if (DmMessage::withTrashed()->whereObjectUri($id)->exists()) { + return true; + } + + return Status::withTrashed()->where('uri', $id)->exists() + || Status::withTrashed()->where('object_url', $id)->exists(); + } + + /** + * Everyone the Note is addressed to, other than its author. Null means + * the message cannot be a conversation here: nobody local is addressed, + * or more people than a group allows. + * + * Addressees that do not resolve to an actor are left out. Everyone who + * does resolve was named by the sender, so the message still only reaches + * people it was meant for. + * + * @return Collection|null + */ + protected function resolveParticipants(array $object, Profile $actor): ?Collection + { + $audience = array_values(array_filter( + DirectMessageValidator::audience($object), + fn (string $uri) => $uri !== $actor->remote_url + )); + + if (empty($audience)) { + return null; + } + + if (count($audience) + 1 > (int) config('dm.groups.max_participants')) { + return null; + } + + if (count($audience) > 1 && ! config('dm.groups.enabled')) { + return null; + } + + $fetches = 0; + $maxFetches = (int) config('dm.federation.max_actor_fetches'); + $resolved = collect(); + + foreach ($audience as $uri) { + $host = parse_url($uri, PHP_URL_HOST); + + if (! is_string($host)) { + continue; + } + + if (Helpers::isLocalDomain($host)) { + $local = FollowersSyncService::resolveLocalActor($uri); + $profile = $local ? Profile::find($local->id) : null; + + if ($profile && $profile->status === null && $profile->user_id) { + $resolved->put($profile->id, $profile); + } + + continue; + } + + $profile = Profile::whereRemoteUrl($uri)->first(); + + if (! $profile && $fetches < $maxFetches && Helpers::validateUrl($uri)) { + $fetches++; + + try { + $profile = Helpers::profileFetch($uri); + } catch (Throwable) { + $profile = null; + } + } + + if ($profile && $profile->status === null && $profile->id !== $actor->id) { + $resolved->put($profile->id, $profile); + } + } + + if ($resolved->filter(fn (Profile $profile) => $profile->domain === null)->isEmpty()) { + return null; + } + + return $resolved->values(); + } + + /** + * While every reader still has the conversation as a request, only so + * many messages from this sender are kept. + * + * @param Collection $readers + */ + protected function requestLimitReached(DmConversation $conversation, Profile $actor, Collection $readers): bool + { + $accepted = DmConversationParticipant::where('conversation_id', $conversation->id) + ->whereIn('profile_id', $readers->pluck('id')) + ->where('state', DmConversationParticipant::STATE_ACTIVE) + ->exists(); + + if ($accepted) { + return false; + } + + $stored = DmMessage::where('conversation_id', $conversation->id) + ->where('profile_id', $actor->id) + ->count(); + + return $stored >= (int) config('dm.requests.inbound_limit'); + } + + /** + * @return Collection + */ + protected function storeAttachments(array $object, Profile $actor): Collection + { + $attachments = $object['attachment'] ?? []; + + if (! is_array($attachments) || empty($attachments)) { + return collect(); + } + + if (! array_is_list($attachments)) { + $attachments = [$attachments]; + } + + $allowed = explode(',', (string) config_cache('pixelfed.media_types')); + $stored = collect(); + + foreach (array_slice($attachments, 0, (int) config('dm.max_media')) as $attachment) { + if (! is_array($attachment)) { + continue; + } + + $mime = $attachment['mediaType'] ?? null; + $url = $attachment['url'] ?? null; + + if (is_array($url)) { + $url = $url['href'] ?? ($url[0]['href'] ?? null); + } + + if (! is_string($mime) || ! in_array($mime, $allowed, true)) { + continue; + } + + if (! is_string($url) || strlen($url) > 255 || ! Helpers::validateUrl($url)) { + continue; + } + + $media = new Media; + $media->remote_media = true; + $media->status_id = null; + $media->profile_id = $actor->id; + $media->user_id = null; + $media->media_path = $url; + $media->remote_url = $url; + $media->mime = $mime; + $media->version = 3; + $media->order = $stored->count() + 1; + $media->is_nsfw = (bool) ($object['sensitive'] ?? false); + $media->blurhash = is_string($attachment['blurhash'] ?? null) ? $attachment['blurhash'] : null; + $media->caption = is_string($attachment['name'] ?? null) + ? mb_substr(Purify::clean($attachment['name']), 0, 1000) + : null; + + if (is_numeric($attachment['width'] ?? null) && is_numeric($attachment['height'] ?? null)) { + $media->width = (int) $attachment['width']; + $media->height = (int) $attachment['height']; + } + + try { + $media->save(); + } catch (Throwable) { + continue; + } + + $stored->push($media); + } + + return $stored; + } + + protected function parentId(DmConversation $conversation, mixed $inReplyTo): ?int + { + $uri = $this->uri($inReplyTo); + + if (! $uri) { + return null; + } + + $id = DmMessage::whereObjectUri($uri) + ->where('conversation_id', $conversation->id) + ->value('id'); + + return $id ? (int) $id : null; + } + + /** + * A property that should be an id: a string, or an object carrying one. + */ + protected function uri(mixed $value): ?string + { + if (is_array($value)) { + $value = $value['id'] ?? (array_is_list($value) ? ($value[0] ?? null) : null); + } + + if (! is_string($value) || $value === '' || strlen($value) > 1024) { + return null; + } + + return $value; + } + + /** + * Messages are kept as plain text. Paragraphs and line breaks survive, + * markup does not, and the mentions Mastodon puts in front of every + * direct message are dropped because the participants already say who it + * is for. + */ + public static function plainText(?string $html): ?string + { + if ($html === null || trim($html) === '') { + return null; + } + + $html = preg_replace('//i', "\n", $html); + $html = preg_replace('/<\/p>\s*]*>/i', "\n\n", $html); + + $text = strip_tags(Purify::clean($html)); + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = str_replace("\u{00A0}", ' ', $text); + + $text = preg_replace('/^\s*(?:@[\p{L}\p{N}_.\-]+(?:@[\p{L}\p{N}_.\-]+)?[\s,:]*)+/u', '', $text); + + $text = preg_replace('/[ \t]+/', ' ', $text); + $text = preg_replace('/ ?\n ?/', "\n", $text); + $text = preg_replace('/\n{3,}/', "\n\n", $text); + $text = trim($text); + + if ($text === '') { + return null; + } + + return mb_substr($text, 0, self::MAX_BODY_LENGTH); + } +} diff --git a/app/Federation/Validators/DirectMessageValidator.php b/app/Federation/Validators/DirectMessageValidator.php new file mode 100644 index 000000000..03956a292 --- /dev/null +++ b/app/Federation/Validators/DirectMessageValidator.php @@ -0,0 +1,133 @@ + + */ + public static function audience(array $object): array + { + $audience = []; + + foreach (['to', 'cc'] as $field) { + $value = $object[$field] ?? []; + + if (is_string($value)) { + $value = [$value]; + } + + if (! is_array($value)) { + continue; + } + + foreach ($value as $entry) { + if (is_array($entry)) { + $entry = $entry['id'] ?? null; + } + + if (is_string($entry) && $entry !== '') { + $audience[] = $entry; + } + } + } + + return array_values(array_unique($audience)); + } + + /** + * A Note is direct when it is addressed to people and nothing else: not + * the public collection and not anyone's followers. + * + * This has to be decided before a Note is considered as a post or a + * reply. Those paths store anything that is not public as followers-only, + * which would show a private message to the sender's local followers. + */ + public static function isDirect(array $object, Profile $actor): bool + { + if (($object['type'] ?? null) !== 'Note') { + return false; + } + + $audience = self::audience($object); + + if (empty($audience)) { + return false; + } + + foreach ($audience as $uri) { + if (in_array($uri, self::PUBLIC_URIS, true)) { + return false; + } + + if (self::isFollowersCollection($uri, $actor)) { + return false; + } + } + + return true; + } + + /** + * Followers collections do not share a URL shape across software, but + * every known implementation has a /followers segment in it. + */ + public static function isFollowersCollection(string $uri, Profile $actor): bool + { + if ($actor->followers_url && $uri === $actor->followers_url) { + return true; + } + + return str_contains(strtolower($uri), '/followers'); + } + + /** + * The object itself is well formed. Whether the sender is allowed to + * speak for it is checked by the inbox before this runs. + */ + public static function validate(array $object): bool + { + $id = Helpers::pluckval($object['id'] ?? null); + + if (! is_string($id) || strlen($id) > 1024 || ! Helpers::validateUrl($id)) { + return false; + } + + if (isset($object['content']) && ! is_string($object['content'])) { + return false; + } + + if (isset($object['attachment']) && ! is_array($object['attachment'])) { + return false; + } + + $hasContent = isset($object['content']) && trim(strip_tags($object['content'])) !== ''; + $hasAttachment = ! empty($object['attachment']); + + if (! $hasContent && ! $hasAttachment) { + return false; + } + + if (isset($object['published'])) { + $published = Helpers::pluckval($object['published']); + + if (! is_string($published) || ! Helpers::validateTimestamp($published)) { + return false; + } + } + + return true; + } +} diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 970fac08a..6bbc1d5e0 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -28,9 +28,10 @@ use App\Models\Avatar; use App\Models\Bookmark; use App\Models\Collection; use App\Models\CollectionItem; -use App\Models\Conversation; use App\Models\CustomFilter; -use App\Models\DirectMessage; +use App\Models\DmConversation; +use App\Models\DmConversationParticipant; +use App\Models\DmMessage; use App\Models\Follower; use App\Models\FollowRequest; use App\Models\Hashtag; @@ -50,6 +51,8 @@ use App\Services\BookmarkService; use App\Services\BouncerService; use App\Services\CollectionService; use App\Services\CustomEmojiService; +use App\Services\DirectMessagePayloadService; +use App\Services\DirectMessageService; use App\Services\DiscoverService; use App\Services\FollowerService; use App\Services\HomeTimelineService; @@ -3220,7 +3223,10 @@ class ApiV1Controller extends Controller /** * GET /api/v1/conversations * - * Not implemented + * Mastodon compatible view of direct message conversations. Group + * conversations are only included when `include_groups` is set, because + * older clients open a conversation by its first account and would show + * a group as a one-to-one thread. */ public function conversations(Request $request) { @@ -3233,106 +3239,64 @@ class ApiV1Controller extends Controller 'min_id' => 'nullable|integer', 'max_id' => 'nullable|integer', 'since_id' => 'nullable|integer', + 'include_groups' => 'sometimes', ]); - $limit = $request->input('limit', 20); - if ($limit > 20) { - $limit = 20; - } + $limit = min((int) $request->input('limit', 20), 20); $scope = $request->input('scope', 'inbox'); $user = $request->user(); - $min_id = $request->input('min_id'); $max_id = $request->input('max_id'); - $since_id = $request->input('since_id'); + $since_id = $request->input('since_id') ?? $request->input('min_id'); + $includeGroups = $request->boolean('include_groups'); + + $service = app(DirectMessageService::class); + $payloads = app(DirectMessagePayloadService::class); - if ($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id)) { + if (! $service->canUseDirectMessages($user)) { return []; } $pid = $user->profile_id; - $isPgsql = db_is_pgsql(); - - if ($isPgsql) { - $dms = DirectMessage::when($scope === 'inbox', function ($q) use ($pid) { - return $q->whereIsHidden(false) - ->where(function ($query) use ($pid) { - $query->where('to_id', $pid) - ->orWhere('from_id', $pid); - }); - }) - ->when($scope === 'sent', function ($q) use ($pid) { - return $q->whereFromId($pid) - ->groupBy(['to_id', 'id']); - }) - ->when($scope === 'requests', function ($q) use ($pid) { - return $q->whereToId($pid) - ->whereIsHidden(true); - }); - } else { - $dms = Conversation::when($scope === 'inbox', function ($q) use ($pid) { - return $q->whereIsHidden(false) - ->where(function ($query) use ($pid) { - $query->where('to_id', $pid) - ->orWhere('from_id', $pid); - }) - ->orderByDesc('status_id') - ->groupBy(['to_id', 'from_id']); - }) - ->when($scope === 'sent', function ($q) use ($pid) { - return $q->whereFromId($pid) - ->groupBy('to_id'); - }) - ->when($scope === 'requests', function ($q) use ($pid) { - return $q->whereToId($pid) - ->whereIsHidden(true); - }); - } - - if ($min_id) { - $dms = $dms->where('id', '>', $min_id); - } - if ($max_id) { - $dms = $dms->where('id', '<', $max_id); - } - if ($since_id) { - $dms = $dms->where('id', '>', $since_id); - } - - $dms = $dms->orderByDesc('status_id')->orderBy('id'); - - $dmResults = $dms->limit($limit + 1)->get(); - - $hasNextPage = $dmResults->count() > $limit; + $rows = DmConversationParticipant::query() + ->join('dm_conversations', 'dm_conversations.id', '=', 'dm_conversation_participants.conversation_id') + ->where('dm_conversation_participants.profile_id', $pid) + ->whereNotNull('dm_conversation_participants.last_activity_at') + ->whereNull('dm_conversation_participants.hidden_at') + ->whereNotNull('dm_conversations.last_message_id') + ->where( + 'dm_conversation_participants.state', + $scope === 'requests' ? DmConversationParticipant::STATE_REQUEST : DmConversationParticipant::STATE_ACTIVE + ) + ->when(! $includeGroups, fn ($q) => $q->where('dm_conversations.type', DmConversation::TYPE_DM)) + ->when($scope === 'sent', fn ($q) => $q->where('dm_conversations.created_by_profile_id', $pid)) + ->when($max_id, fn ($q) => $q->where('dm_conversations.last_message_id', '<', $max_id)) + ->when($since_id, fn ($q) => $q->where('dm_conversations.last_message_id', '>', $since_id)) + ->orderByDesc('dm_conversations.last_message_id') + ->limit($limit + 1) + ->get(['dm_conversation_participants.*', 'dm_conversations.last_message_id']); + + $hasNextPage = $rows->count() > $limit; + $rows = $rows->take($limit); + + $conversations = DmConversation::whereIn('id', $rows->pluck('conversation_id'))->get()->keyBy('id'); + $members = DmConversationParticipant::whereIn('conversation_id', $rows->pluck('conversation_id')) + ->orderBy('id') + ->get() + ->groupBy('conversation_id'); + $lastMessages = DmMessage::with('media')->whereIn('id', $rows->pluck('last_message_id'))->get()->keyBy('id'); + $blocked = $payloads->blockedIds($pid); - if ($hasNextPage) { - $dmResults = $dmResults->take($limit); - } + $transformedDms = $rows->map(function ($row) use ($conversations, $members, $lastMessages, $blocked, $payloads, $pid) { + $conversation = $conversations->get($row->conversation_id); + $last = $lastMessages->get($conversation?->last_message_id); - $transformedDms = $dmResults->map(function ($dm) use ($pid) { - $from = $pid == $dm->to_id ? $dm->from_id : $dm->to_id; + if (! $conversation || ! $last || in_array((int) $last->profile_id, $blocked, true)) { + return null; + } - return [ - 'id' => $dm->id, - 'unread' => false, - 'accounts' => [ - AccountService::getMastodon($from, true), - ], - 'last_status' => StatusService::getDirectMessage($dm->status_id), - ]; - }) - ->filter(function ($dm) { - return $dm - && ! empty($dm['last_status']) - && isset($dm['accounts']) - && count($dm['accounts']) - && isset($dm['accounts'][0]) - && isset($dm['accounts'][0]['id']); - }) - ->unique(function ($item) { - return $item['accounts'][0]['id']; - }) - ->values(); + return $payloads->mastodonConversation($conversation, $row, $members->get($row->conversation_id, collect()), $last, $pid); + })->filter()->values(); $links = []; @@ -3342,8 +3306,9 @@ class ApiV1Controller extends Controller ['limit' => $limit] )); - $firstId = $transformedDms->first()['id']; - $lastId = $transformedDms->last()['id']; + // Mastodon pages conversations by the id of their last status + $firstId = $transformedDms->first()['last_status']['id']; + $lastId = $transformedDms->last()['last_status']['id']; $firstLink = $baseUrl; $links[] = '<'.$firstLink.'>; rel="first"'; @@ -3367,6 +3332,58 @@ class ApiV1Controller extends Controller return $this->json($transformedDms); } + /** + * DELETE /api/v1/conversations/{id} + * + * Removes the conversation from the caller's list. Nothing is deleted for + * the other participants. + */ + public function conversationDelete(Request $request, $id) + { + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('write'), 403); + + $service = app(DirectMessageService::class); + $found = is_numeric($id) ? $service->conversationFor($id, $request->user()->profile_id) : null; + abort_if(! $found, 404); + + $service->setHidden($found[1], true); + + return $this->json([]); + } + + /** + * POST /api/v1/conversations/{id}/read + */ + public function conversationRead(Request $request, $id) + { + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('write'), 403); + + $service = app(DirectMessageService::class); + $payloads = app(DirectMessagePayloadService::class); + $pid = $request->user()->profile_id; + + $found = is_numeric($id) ? $service->conversationFor($id, $pid) : null; + abort_if(! $found, 404); + + [$conversation, $participant] = $found; + + $service->markRead($participant); + + $res = $payloads->mastodonConversation( + $conversation, + $participant, + DmConversationParticipant::where('conversation_id', $conversation->id)->orderBy('id')->get(), + $conversation->last_message_id ? DmMessage::with('media')->find($conversation->last_message_id) : null, + $pid + ); + + abort_if(! $res, 404); + + return $this->json($res); + } + /** * GET /api/v1/statuses/{id} * @@ -3881,6 +3898,7 @@ class ApiV1Controller extends Controller if ( Media::whereUserId($user->id) ->whereNull('status_id') + ->notInDirectMessage() ->find($ids) ->count() == 0 ) { @@ -3908,7 +3926,7 @@ class ApiV1Controller extends Controller if ($k + 1 > (int) config_cache('pixelfed.max_album_length')) { continue; } - $m = Media::whereUserId($user->id)->whereNull('status_id')->findOrFail($v); + $m = Media::whereUserId($user->id)->whereNull('status_id')->notInDirectMessage()->findOrFail($v); if ($m->profile_id !== $user->profile_id || $m->status_id) { abort(403, 'Invalid media id'); } diff --git a/app/Http/Controllers/Api/ApiV1Dot1Controller.php b/app/Http/Controllers/Api/ApiV1Dot1Controller.php index 9c42a1f63..c87a43790 100644 --- a/app/Http/Controllers/Api/ApiV1Dot1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Dot1Controller.php @@ -14,6 +14,8 @@ use App\Jobs\VideoPipeline\VideoThumbnail; use App\Mail\ConfirmAppEmail; use App\Mail\PasswordChange; use App\Models\AccountLog; +use App\Models\DmConversationParticipant; +use App\Models\DmMessage; use App\Models\EmailVerification; use App\Models\Follower; use App\Models\Media; @@ -111,7 +113,7 @@ class ApiV1Dot1Controller extends Controller $validator = Validator::make($request->all(), [ 'report_type' => ['required', 'string', Rule::in(self::REPORT_TYPES)], 'object_id' => ['required', 'integer', 'min:1'], - 'object_type' => ['required', 'string', Rule::in(['post', 'user', 'story'])], + 'object_type' => ['required', 'string', Rule::in(['post', 'user', 'story', 'direct_message'])], 'message' => ['nullable', 'string'], ]); @@ -129,8 +131,15 @@ class ApiV1Dot1Controller extends Controller return $this->error('Message is too long', 400, ['error_code' => 'ERROR_MESSAGE_TOO_LONG']); } + // Older clients report a direct message as a post, using the id the + // thread endpoint gave them + if ($objectType === 'post' && ! Status::whereKey($objectId)->exists()) { + $objectType = 'direct_message'; + } + [$object, $modelClass, $reportedProfileId] = match ($objectType) { 'post' => [$post = Status::find($objectId), Status::class, $post?->profile_id], + 'direct_message' => [$dm = $this->reportableDirectMessage($user->profile_id, $objectId), DmMessage::class, $dm?->profile_id], 'user' => [$profile = Profile::find($objectId), Profile::class, $profile?->id], 'story' => [$story = Story::whereActive(true)->find($objectId), Story::class, $story?->profile_id], default => [null, null, null], @@ -183,6 +192,25 @@ class ApiV1Dot1Controller extends Controller ]); } + /** + * A direct message can only be reported by someone who is in the + * conversation it was sent to. + */ + protected function reportableDirectMessage(int $profileId, $messageId): ?DmMessage + { + $message = DmMessage::find($messageId); + + if (! $message) { + return null; + } + + $isParticipant = DmConversationParticipant::where('conversation_id', $message->conversation_id) + ->where('profile_id', $profileId) + ->exists(); + + return $isParticipant ? $message : null; + } + protected function sanitizeReportMessage(?string $message): string|false|null { if (! $message) { diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 90b08fb02..3124fa094 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -100,6 +100,14 @@ class ApiV2Controller extends Controller 'accounts' => [ 'max_featured_tags' => 0, ], + 'direct_messages' => [ + 'max_characters' => (int) config('dm.max_message_length'), + 'max_media_attachments' => (int) config('dm.max_media'), + 'group_chats' => [ + 'enabled' => (bool) config('dm.groups.enabled'), + 'max_participants' => (int) config('dm.groups.max_participants'), + ], + ], 'statuses' => [ 'max_characters' => (int) config_cache('pixelfed.max_caption_length'), 'max_media_attachments' => (int) config_cache('pixelfed.max_album_length'), diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index 681351b45..a75012dcf 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -16,6 +16,7 @@ use App\Models\Profile; use App\Models\Status; use App\Services\AccountService; use App\Services\CollectionService; +use App\Services\DirectMessageService; use App\Services\MediaBlocklistService; use App\Services\MediaPathService; use App\Services\MediaStorageService; @@ -226,6 +227,7 @@ class ComposeController extends Controller $media = Media::whereNull('status_id') ->whereUserId(Auth::id()) + ->notInDirectMessage() ->findOrFail($request->input('id')); MediaStorageService::delete($media, true); @@ -592,7 +594,7 @@ class ComposeController extends Controller continue; } $m = Media::findOrFail($media['id']); - if ($m->profile_id !== $profile->id || $m->status_id) { + if ($m->profile_id !== $profile->id || $m->status_id || DirectMessageService::isMessageMedia($m->id)) { abort(403, 'Invalid media id'); } $m->filter_class = in_array($media['filter_class'], Filter::classes()) ? $media['filter_class'] : null; diff --git a/app/Http/Controllers/DirectConversationController.php b/app/Http/Controllers/DirectConversationController.php new file mode 100644 index 000000000..52960e912 --- /dev/null +++ b/app/Http/Controllers/DirectConversationController.php @@ -0,0 +1,416 @@ +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; + } +} diff --git a/app/Http/Controllers/DirectMessageController.php b/app/Http/Controllers/DirectMessageController.php index 222d3c77b..13b45e6a8 100644 --- a/app/Http/Controllers/DirectMessageController.php +++ b/app/Http/Controllers/DirectMessageController.php @@ -2,37 +2,42 @@ namespace App\Http\Controllers; -use App\Jobs\DirectPipeline\DirectDeletePipeline; -use App\Jobs\DirectPipeline\DirectDeliverPipeline; -use App\Jobs\StatusPipeline\StatusDelete; -use App\Models\Conversation; -use App\Models\DirectMessage; +use App\Exceptions\DirectMessageException; +use App\Models\DmConversation; +use App\Models\DmConversationParticipant; +use App\Models\DmMessage; use App\Models\Media; use App\Models\Profile; -use App\Models\Status; -use App\Models\UserFilter; use App\Services\AccountService; +use App\Services\DirectMessagePayloadService; +use App\Services\DirectMessageService; use App\Services\FollowerService; use App\Services\MediaBlocklistService; use App\Services\MediaPathService; -use App\Services\MediaService; -use App\Services\NotificationService; -use App\Services\StatusService; +use App\Services\MediaStorageService; use App\Services\UserFilterService; use App\Services\UserRoleService; use App\Services\UserStorageService; use App\Services\WebfingerService; use App\Util\ActivityPub\Helpers; -use App\Util\Lexer\Autolink; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Arr; use Illuminate\Support\Str; +/** + * The original one-to-one direct message endpoints. + * + * Threads here are keyed by the other person's profile id, which is what the + * Blade UI and the older mobile app speak. They are thin wrappers around the + * conversation model and only ever see one-to-one conversations. Anything new + * should use DirectConversationController. + */ class DirectMessageController extends Controller { - public function __construct() - { + public function __construct( + protected DirectMessageService $service, + protected DirectMessagePayloadService $payloads + ) { $this->middleware('auth'); } @@ -44,198 +49,91 @@ class DirectMessageController extends Controller ]); $user = $request->user(); - if ($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id)) { + if (! $this->service->canUseDirectMessages($user)) { return []; } - $profile = $user->profile_id; + $pid = $user->profile_id; $action = $request->input('a', 'inbox'); - $page = $request->input('page', 1); $limit = 8; - $offset = ($page - 1) * $limit; - - $baseQuery = DirectMessage::select( - 'id', 'type', 'to_id', 'from_id', 'status_id', - 'is_hidden', 'meta', 'created_at', 'read_at' - )->with(['author', 'status', 'recipient']); - - if (db_is_pgsql()) { - $query = match ($action) { - 'inbox' => $baseQuery->whereToId($profile) - ->whereIsHidden(false) - ->orderBy('created_at', 'desc'), - 'sent' => $baseQuery->whereFromId($profile) - ->orderBy('created_at', 'desc'), - 'filtered' => $baseQuery->whereToId($profile) - ->whereIsHidden(true) - ->orderBy('created_at', 'desc'), - default => throw new \InvalidArgumentException('Invalid action') - }; - - $dms = $query->offset($offset) - ->limit($limit) - ->get(); - - $dms = $action === 'sent' ? - $dms->unique('to_id') : - $dms->unique('from_id'); - } else { - $query = match ($action) { - 'inbox' => $baseQuery->whereToId($profile) - ->whereIsHidden(false) - ->groupBy('from_id', 'id', 'type', 'to_id', 'status_id', - 'is_hidden', 'meta', 'created_at', 'read_at') - ->orderBy('created_at', 'desc'), - 'sent' => $baseQuery->whereFromId($profile) - ->groupBy('to_id', 'id', 'type', 'from_id', 'status_id', - 'is_hidden', 'meta', 'created_at', 'read_at') - ->orderBy('created_at', 'desc'), - 'filtered' => $baseQuery->whereToId($profile) - ->whereIsHidden(true) - ->groupBy('from_id', 'id', 'type', 'to_id', 'status_id', - 'is_hidden', 'meta', 'created_at', 'read_at') - ->orderBy('created_at', 'desc'), - default => throw new \InvalidArgumentException('Invalid action') - }; - - $dms = $query->offset($offset) - ->limit($limit) - ->get(); - } - - $mappedDms = $dms->map(function ($r) use ($action) { - if ($action === 'sent') { - return [ - 'id' => (string) $r->to_id, - 'name' => $r->recipient->name, - 'username' => $r->recipient->username, - 'avatar' => $r->recipient->avatarUrl(), - 'url' => $r->recipient->url(), - 'isLocal' => (bool) ! $r->recipient->domain, - 'domain' => $r->recipient->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, - 'messages' => [], - ]; + $offset = ((int) $request->input('page', 1) - 1) * $limit; + + $rows = DmConversationParticipant::query() + ->join('dm_conversations', 'dm_conversations.id', '=', 'dm_conversation_participants.conversation_id') + ->where('dm_conversations.type', DmConversation::TYPE_DM) + ->where('dm_conversation_participants.profile_id', $pid) + ->whereNotNull('dm_conversation_participants.last_activity_at') + ->whereNull('dm_conversation_participants.hidden_at') + ->when($action === 'filtered', fn ($q) => $q->where('dm_conversation_participants.state', DmConversationParticipant::STATE_REQUEST)) + ->when($action !== 'filtered', fn ($q) => $q->where('dm_conversation_participants.state', DmConversationParticipant::STATE_ACTIVE)) + ->when($action === 'sent', fn ($q) => $q->where('dm_conversations.created_by_profile_id', $pid)) + ->orderByDesc('dm_conversation_participants.last_activity_at') + ->offset($offset) + ->limit($limit) + ->get(['dm_conversation_participants.*', 'dm_conversations.last_message_id']); + + $others = DmConversationParticipant::whereIn('conversation_id', $rows->pluck('conversation_id')) + ->where('profile_id', '!=', $pid) + ->pluck('profile_id', 'conversation_id'); + + $profiles = Profile::whereIn('id', $others->values())->get()->keyBy('id'); + $lastIds = $rows->pluck('last_message_id', 'conversation_id'); + $lastMessages = DmMessage::whereIn('id', $lastIds->filter())->get()->keyBy('id'); + + $threads = $rows->map(function ($row) use ($others, $profiles, $lastIds, $lastMessages) { + $other = $profiles->get($others->get($row->conversation_id)); + + if (! $other) { + return null; } + $last = $lastMessages->get($lastIds->get($row->conversation_id)); + return [ - 'id' => (string) $r->from_id, - 'name' => $r->author->name, - 'username' => $r->author->username, - 'avatar' => $r->author->avatarUrl(), - 'url' => $r->author->url(), - 'isLocal' => (bool) ! $r->author->domain, - 'domain' => $r->author->domain, - 'timeAgo' => $r->created_at->diffForHumans(null, true, true), - 'lastMessage' => $r->status->caption, + 'id' => (string) $other->id, + 'name' => $other->name, + 'username' => $other->username, + 'avatar' => $other->avatarUrl(), + 'url' => $other->url(), + 'isLocal' => (bool) ! $other->domain, + 'domain' => $other->domain, + 'timeAgo' => $row->last_activity_at->diffForHumans(null, true, true), + 'lastMessage' => $last?->body, 'messages' => [], ]; - }); + })->filter()->values(); - return response()->json($mappedDms->values()); + return response()->json($threads); } public function create(Request $request): JsonResponse { $this->validate($request, [ 'to_id' => 'required', - 'message' => 'required|string|min:1|max:500', + 'message' => 'required|string|min:1|max:'.(int) config('dm.max_message_length'), 'type' => 'required|in:text,emoji', ]); $user = $request->user(); - abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); - if (! $user->is_admin) { - if ((bool) ! config_cache('instance.allow_new_account_dms')) { - abort_if($user->created_at->gt(now()->subHours(72)), 400, 'You need to wait a bit before you can DM another account'); - } - } + $this->authorizeSend($user); + $profile = $user->profile; $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id')); - abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403); - $msg = $request->input('message'); - - if ((! $recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) { - if ($recipient->follows($profile) == true) { - $hidden = false; - } else { - $hidden = true; - } - } else { - $hidden = false; - } - - $status = new Status; - $status->profile_id = $profile->id; - $status->caption = $msg; - $status->visibility = 'direct'; - $status->scope = 'direct'; - $status->in_reply_to_profile_id = $recipient->id; - $status->save(); - - $dm = new DirectMessage; - $dm->to_id = $recipient->id; - $dm->from_id = $profile->id; - $dm->status_id = $status->id; - $dm->is_hidden = $hidden; - $dm->type = $request->input('type'); - $dm->save(); - - Conversation::updateOrInsert( - [ - 'to_id' => $recipient->id, - 'from_id' => $profile->id, - ], - [ - 'type' => $dm->type, - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => $hidden, - ] - ); - - if (filter_var($msg, FILTER_VALIDATE_URL)) { - if (Helpers::validateUrl($msg)) { - $dm->type = 'link'; - $dm->meta = [ - 'domain' => parse_url($msg, PHP_URL_HOST), - 'local' => parse_url($msg, PHP_URL_HOST) == - parse_url(config('app.url'), PHP_URL_HOST), - ]; - $dm->save(); - } - } - - $nf = UserFilter::whereUserId($recipient->id) - ->whereFilterableId($profile->id) - ->whereFilterableType(Profile::class) - ->whereFilterType('dm.mute') - ->exists(); - - if ($recipient->domain == null && $hidden === false && ! $nf) { - NotificationService::createNotification($recipient->id, $profile->id, 'dm', $dm->id, DirectMessage::class); - } + abort_if(! $this->service->canMessage($profile, $recipient), 403); - if ($recipient->domain) { - $this->remoteDeliver($dm); - } + $conversation = $this->service->findOrCreateDm($profile, $recipient); - $res = [ - 'id' => (string) $dm->id, - 'isAuthor' => $profile->id == $dm->from_id, - 'reportId' => (string) $dm->status_id, - 'hidden' => (bool) $dm->is_hidden, - 'type' => $dm->type, - 'text' => $dm->status->caption, - 'media' => null, - 'timeAgo' => $dm->created_at->diffForHumans(null, null, true), - 'seen' => $dm->read_at != null, - 'meta' => $dm->meta, - ]; + $message = $this->service->sendMessage($conversation, $profile, [ + 'body' => $request->input('message'), + 'type' => $request->input('type'), + ]); - return response()->json($res); + return response()->json($this->payloads->legacyMessage( + $message, + $profile->id, + $this->isRequest($conversation, $recipient->id) + )); } public function thread(Request $request): JsonResponse @@ -247,85 +145,47 @@ class DirectMessageController extends Controller ]); $user = $request->user(); - abort_if( - $user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), - 403, - 'Invalid permissions for this action' - ); + abort_if(! $this->service->canUseDirectMessages($user), 403, 'Invalid permissions for this action'); $uid = $user->profile_id; - $pid = $request->input('pid'); - $max_id = $request->input('max_id'); - $min_id = $request->input('min_id'); - - $profile = Profile::findOrFail($pid); - - $query = DirectMessage::select( - 'id', - 'is_hidden', - 'from_id', - 'to_id', - 'type', - 'status_id', - 'meta', - 'created_at', - 'read_at' - )->with(['status']); - - if ($min_id) { - $res = $query->where('id', '>', $min_id) - ->where(function ($query) use ($pid, $uid) { - $query->where('from_id', $pid)->where('to_id', $uid); - })->orWhere(function ($query) use ($pid, $uid) { - $query->where('from_id', $uid)->where('to_id', $pid); - }) - ->orderBy('id', 'asc') - ->take(8) - ->get() - ->reverse(); - } elseif ($max_id) { - $res = $query->where('id', '<', $max_id) - ->where(function ($query) use ($pid, $uid) { - $query->where('from_id', $pid)->where('to_id', $uid); - })->orWhere(function ($query) use ($pid, $uid) { - $query->where('from_id', $uid)->where('to_id', $pid); - }) - ->orderBy('id', 'desc') - ->take(8) - ->get(); - } else { - $res = $query->where(function ($query) use ($pid, $uid) { - $query->where('from_id', $pid)->where('to_id', $uid); - })->orWhere(function ($query) use ($pid, $uid) { - $query->where('from_id', $uid)->where('to_id', $pid); - }) - ->orderBy('id', 'desc') - ->take(8) - ->get(); - } - - $messages = $res->filter(function ($message) { - return $message && $message->status; - })->map(function ($message) use ($uid) { - $firstMedia = $message->status->media->sortBy('order')->first(); + $profile = Profile::findOrFail($request->input('pid')); + + $conversation = $this->service->findDm($uid, $profile->id); + $messages = collect(); + $muted = false; + + if ($conversation) { + $viewer = $this->service->participant($conversation, $uid); + $other = $this->service->participant($conversation, $profile->id); + $muted = (bool) $viewer?->muted_at; + $hidden = (bool) ($viewer?->isRequest() || $other?->isRequest()); + + $query = DmMessage::with('media') + ->where('conversation_id', $conversation->id) + ->whereNotIn('profile_id', $this->payloads->blockedIds($uid) ?: [0]); + + if ($request->filled('min_id')) { + $res = $query->where('id', '>', $request->input('min_id')) + ->orderBy('id') + ->take(8) + ->get() + ->reverse(); + } else { + if ($request->filled('max_id')) { + $query->where('id', '<', $request->input('max_id')); + } - return [ - 'id' => (string) $message->id, - 'hidden' => (bool) $message->is_hidden, - 'isAuthor' => $uid == $message->from_id, - 'type' => $message->type, - 'text' => $message->status->caption, - 'media' => $firstMedia ? $firstMedia->url() : null, - 'carousel' => MediaService::get($message->status_id), - 'created_at' => $message->created_at->format('c'), - 'timeAgo' => $message->created_at->diffForHumans(null, null, true), - 'seen' => $message->read_at != null, - 'reportId' => (string) $message->status_id, - 'meta' => is_string($message->meta) ? json_decode($message->meta, true) : $message->meta, - ]; - })->values(); + $res = $query->orderByDesc('id')->take(8)->get(); + } - $filters = UserFilterService::mutes($uid); + $messages = $res->map(fn (DmMessage $message) => $this->payloads->legacyMessage( + $message, + $uid, + $hidden, + $other?->last_read_message_id, + $viewer?->last_read_message_id + ))->values(); + } return response()->json([ 'id' => (string) $profile->id, @@ -333,7 +193,7 @@ class DirectMessageController extends Controller 'username' => $profile->username, 'avatar' => $profile->avatarUrl(), 'url' => $profile->url(), - 'muted' => in_array($profile->id, $filters), + 'muted' => $muted, 'isLocal' => (bool) ! $profile->domain, 'domain' => $profile->domain, 'created_at' => $profile->created_at->format('c'), @@ -341,6 +201,7 @@ class DirectMessageController extends Controller 'timeAgo' => $profile->created_at->diffForHumans(null, true, true), 'lastMessage' => '', 'messages' => $messages, + 'conversation_id' => $conversation ? (string) $conversation->id : null, ], 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } @@ -350,85 +211,14 @@ class DirectMessageController extends Controller 'id' => 'required', ]); - $sid = $request->input('id'); - $pid = $request->user()->profile_id; - - $dm = DirectMessage::whereFromId($pid) - ->whereStatusId($sid) - ->firstOrFail(); + $message = DmMessage::where('profile_id', $request->user()->profile_id) + ->findOrFail($request->input('id')); - $status = Status::whereProfileId($pid) - ->findOrFail($dm->status_id); - - $recipient = AccountService::get($dm->to_id); - - if (! $recipient) { - return response('', 422); - } - - if ($recipient['local'] == false) { - $dmc = $dm; - $this->remoteDelete($dmc); - } else { - StatusDelete::dispatch($status)->onQueue('high'); - } - - if (Conversation::whereStatusId($sid)->count()) { - $latest = DirectMessage::where(['from_id' => $dm->from_id, 'to_id' => $dm->to_id]) - ->orWhere(['to_id' => $dm->from_id, 'from_id' => $dm->to_id]) - ->latest() - ->first(); - - if ($latest->status_id == $sid) { - Conversation::where(['to_id' => $dm->from_id, 'from_id' => $dm->to_id]) - ->update([ - 'updated_at' => $latest->updated_at, - 'status_id' => $latest->status_id, - 'type' => $latest->type, - 'is_hidden' => false, - ]); - - Conversation::where(['to_id' => $dm->to_id, 'from_id' => $dm->from_id]) - ->update([ - 'updated_at' => $latest->updated_at, - 'status_id' => $latest->status_id, - 'type' => $latest->type, - 'is_hidden' => false, - ]); - } else { - Conversation::where([ - 'status_id' => $sid, - 'to_id' => $dm->from_id, - 'from_id' => $dm->to_id, - ])->delete(); - - Conversation::where([ - 'status_id' => $sid, - 'from_id' => $dm->from_id, - 'to_id' => $dm->to_id, - ])->delete(); - } - } - - StatusService::del($status->id, true); - - $status->forceDeleteQuietly(); + $this->service->deleteMessage($message); return [200]; } - public function get(Request $request, $id): JsonResponse - { - $user = $request->user(); - abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); - - $pid = $request->user()->profile_id; - $dm = DirectMessage::whereStatusId($id)->firstOrFail(); - abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404); - - return response()->json($dm, 200, [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } - public function mediaUpload(Request $request): array { $this->validate($request, [ @@ -438,23 +228,16 @@ class DirectMessageController extends Controller 'max:'.config_cache('pixelfed.max_photo_size'), ], 'to_id' => 'required', + 'message' => 'sometimes|nullable|string|max:'.(int) config('dm.max_message_length'), ]); $user = $request->user(); - abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + $this->authorizeSend($user); + $profile = $user->profile; $recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id')); - abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403); - if ((! $recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) { - if ($recipient->follows($profile) == true) { - $hidden = false; - } else { - $hidden = true; - } - } else { - $hidden = false; - } + abort_if(! $this->service->canMessage($profile, $recipient), 403); $accountSize = UserStorageService::get($user->id); abort_if($accountSize === -1, 403, 'Invalid request.'); @@ -481,19 +264,13 @@ class DirectMessageController extends Controller $hash = \hash_file('sha256', $photo->getRealPath()); abort_if(MediaBlocklistService::exists($hash) == true, 451); + $conversation = $this->service->findOrCreateDm($profile, $recipient); + $storagePath = MediaPathService::get($user, 2).Str::random(8); $path = $photo->storePublicly($storagePath); - $status = new Status; - $status->profile_id = $profile->id; - $status->caption = null; - $status->visibility = 'direct'; - $status->scope = 'direct'; - $status->in_reply_to_profile_id = $recipient->id; - $status->save(); - $media = new Media; - $media->status_id = $status->id; + $media->status_id = null; $media->profile_id = $profile->id; $media->user_id = $user->id; $media->media_path = $path; @@ -505,37 +282,23 @@ class DirectMessageController extends Controller $media->filter_name = null; $media->save(); - $dm = new DirectMessage; - $dm->to_id = $recipient->id; - $dm->from_id = $profile->id; - $dm->status_id = $status->id; - $dm->type = Arr::first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo'; - $dm->is_hidden = $hidden; - $dm->save(); - - Conversation::updateOrInsert( - [ - 'to_id' => $recipient->id, - 'from_id' => $profile->id, - ], - [ - 'type' => $dm->type, - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => $hidden, - ] - ); - - UserStorageService::increaseStorageUsed($user->id, $fileSize); + try { + $message = $this->service->sendMessage($conversation, $profile, [ + 'body' => $request->input('message'), + 'media' => collect([$media]), + ]); + } catch (DirectMessageException $e) { + MediaStorageService::delete($media, true); - if ($recipient->domain) { - $this->remoteDeliver($dm); + throw $e; } + UserStorageService::increaseStorageUsed($user->id, $fileSize); + return [ - 'id' => $dm->id, - 'reportId' => (string) $dm->status_id, - 'type' => $dm->type, + 'id' => (string) $message->id, + 'reportId' => (string) $message->id, + 'type' => $message->type, 'url' => $media->url(), ]; } @@ -614,151 +377,71 @@ class DirectMessageController extends Controller 'sid' => 'required', ]); - $pid = $request->input('pid'); - $sid = $request->input('sid'); $user = $request->user(); - abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + abort_if(! $this->service->canUseDirectMessages($user), 403, 'Invalid permissions for this action'); + + $conversation = $this->service->findDm($user->profile_id, (int) $request->input('pid')); + $participant = $conversation ? $this->service->participant($conversation, $user->profile_id) : null; + + if (! $participant) { + return response()->json([]); + } - $ids = DirectMessage::whereToId($request->user()->profile_id) - ->whereFromId($pid) - ->where('status_id', '>=', $sid) + $ids = DmMessage::where('conversation_id', $conversation->id) + ->where('profile_id', $request->input('pid')) + ->where('id', '>=', $request->input('sid')) + ->when($participant->last_read_message_id, fn ($q) => $q->where('id', '>', $participant->last_read_message_id)) ->pluck('id'); if ($ids->isNotEmpty()) { - $now = now(); - DirectMessage::whereIn('id', $ids)->update([ - 'read_at' => $now, - 'updated_at' => $now, - ]); + $this->service->markRead($participant, (int) $ids->max()); } - return response()->json($ids); + return response()->json($ids->map(fn ($id) => (string) $id)->values()); } public function mute(Request $request): array { - $this->validate($request, [ - 'id' => 'required', - ]); - - $user = $request->user(); - abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); - $fid = $request->input('id'); - $pid = $request->user()->profile_id; - - UserFilter::firstOrCreate( - [ - 'user_id' => $pid, - 'filterable_id' => $fid, - 'filterable_type' => Profile::class, - 'filter_type' => 'dm.mute', - ] - ); + $this->toggleMute($request, true); return [200]; } public function unmute(Request $request): array + { + $this->toggleMute($request, false); + + return [200]; + } + + protected function toggleMute(Request $request, bool $muted): void { $this->validate($request, [ 'id' => 'required', ]); $user = $request->user(); - abort_if($user->has_roles && ! UserRoleService::can('can-direct-message', $user->id), 403, 'Invalid permissions for this action'); + abort_if(! $this->service->canUseDirectMessages($user), 403, 'Invalid permissions for this action'); - $fid = $request->input('id'); - $pid = $request->user()->profile_id; + $other = Profile::where('id', '!=', $user->profile_id)->findOrFail($request->input('id')); - $f = UserFilter::whereUserId($pid) - ->whereFilterableId($fid) - ->whereFilterableType(Profile::class) - ->whereFilterType('dm.mute') - ->firstOrFail(); + $conversation = $muted + ? $this->service->findOrCreateDm($user->profile, $other) + : $this->service->findDm($user->profile_id, $other->id); - $f->delete(); + abort_if(! $conversation, 404); - return [200]; + $this->service->setMuted($this->service->participant($conversation, $user->profile_id), $muted); } - public function remoteDeliver($dm): void + protected function authorizeSend($user): void { - $profile = $dm->author; - $url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url; - $status = $dm->status; - - if (! $status) { - return; - } - - $tags = [ - [ - 'type' => 'Mention', - 'href' => $dm->recipient->permalink(), - 'name' => $dm->recipient->emailUrl(), - ], - ]; - - $content = $status->caption ? Autolink::create()->autolink($status->caption) : null; - - $body = [ - '@context' => [ - 'https://w3id.org/security/v1', - 'https://www.w3.org/ns/activitystreams', - ], - 'id' => $dm->status->permalink(), - 'type' => 'Create', - 'actor' => $dm->status->profile->permalink(), - 'published' => $dm->status->created_at->toAtomString(), - 'to' => [$dm->recipient->permalink()], - 'cc' => [], - 'object' => [ - 'id' => $dm->status->url(), - 'type' => 'Note', - 'summary' => null, - 'content' => $content, - 'inReplyTo' => null, - 'published' => $dm->status->created_at->toAtomString(), - 'url' => $dm->status->url(), - 'attributedTo' => $dm->status->profile->permalink(), - 'to' => [$dm->recipient->permalink()], - 'cc' => [], - 'sensitive' => (bool) $dm->status->is_nsfw, - 'attachment' => $dm->status->media()->orderBy('order')->get()->map(function ($media) { - return [ - 'type' => $media->activityVerb(), - 'mediaType' => $media->mime, - 'url' => $media->url(), - 'name' => $media->caption, - ]; - })->toArray(), - 'tag' => $tags, - ], - ]; - - DirectDeliverPipeline::dispatch($profile, $url, $body)->onQueue('high'); + abort_if(! $this->service->canUseDirectMessages($user), 403, 'Invalid permissions for this action'); + abort_if(! $this->service->canInitiateConversation($user), 400, 'You need to wait a bit before you can DM another account'); } - public function remoteDelete($dm): void + protected function isRequest(DmConversation $conversation, int $profileId): bool { - $profile = $dm->author; - $url = $dm->recipient->sharedInbox ?? $dm->recipient->inbox_url; - - $body = [ - '@context' => [ - 'https://www.w3.org/ns/activitystreams', - ], - 'id' => $dm->status->permalink('#delete'), - 'to' => [ - 'https://www.w3.org/ns/activitystreams#Public', - ], - 'type' => 'Delete', - 'actor' => $dm->status->profile->permalink(), - 'object' => [ - 'id' => $dm->status->url(), - 'type' => 'Tombstone', - ], - ]; - DirectDeletePipeline::dispatch($profile, $url, $body)->onQueue('high'); + return (bool) $this->service->participant($conversation, $profileId)?->isRequest(); } } diff --git a/app/Http/Controllers/ReportController.php b/app/Http/Controllers/ReportController.php index be682b682..50abe78e3 100644 --- a/app/Http/Controllers/ReportController.php +++ b/app/Http/Controllers/ReportController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail; +use App\Models\DmConversationParticipant; +use App\Models\DmMessage; use App\Models\Group; use App\Models\Profile; use App\Models\Report; @@ -152,11 +154,27 @@ class ReportController extends Controller switch ($object_type) { case 'post': case 'comment': - $object = Status::findOrFail($object_id); - $object_type = Status::class; + $object = Status::find($object_id); + + // Direct messages are reported from the thread view as a + // post. They are no longer statuses, so look there next. + if (! $object) { + $object = DmMessage::findOrFail($object_id); + + $isParticipant = DmConversationParticipant::where('conversation_id', $object->conversation_id) + ->where('profile_id', $profile->id) + ->exists(); + + abort_if(! $isParticipant, 404); + + $object_type = DmMessage::class; + } else { + $object_type = Status::class; + } + $exists = Report::whereUserId(Auth::id()) ->whereObjectId($object->id) - ->whereObjectType(Status::class) + ->whereObjectType($object_type) ->count(); $rpid = $object->profile_id; diff --git a/app/Http/Controllers/Stories/StoryApiV1Controller.php b/app/Http/Controllers/Stories/StoryApiV1Controller.php index 7fdeccc6c..bfbcd59e1 100644 --- a/app/Http/Controllers/Stories/StoryApiV1Controller.php +++ b/app/Http/Controllers/Stories/StoryApiV1Controller.php @@ -8,14 +8,12 @@ use App\Jobs\StoryPipeline\StoryDelete; use App\Jobs\StoryPipeline\StoryFanout; use App\Jobs\StoryPipeline\StoryReplyDeliver; use App\Jobs\StoryPipeline\StoryViewDeliver; -use App\Models\Conversation; -use App\Models\DirectMessage; use App\Models\Follower; -use App\Models\Notification; use App\Models\Status; use App\Models\Story; use App\Models\StoryView; use App\Services\AccountService; +use App\Services\DirectMessageService; use App\Services\MediaPathService; use App\Services\StoryIndexService; use App\Services\StoryService; @@ -718,42 +716,24 @@ class StoryApiV1Controller extends Controller ]); $status->save(); - $dm = new DirectMessage; - $dm->to_id = $story->profile_id; - $dm->from_id = $pid; - $dm->type = 'story:comment'; - $dm->status_id = $status->id; - $dm->meta = json_encode([ - 'story_username' => $story->profile->username, - 'story_actor_username' => $request->user()->username, - 'story_id' => $story->id, - 'story_media_url' => url(Storage::url($story->path)), - 'caption' => $text, - ]); - $dm->save(); - - Conversation::updateOrInsert( + // Shows up in the conversation with the story author, who is + // notified when they are on this server + app(DirectMessageService::class)->storeStoryMessage( + $request->user()->profile, + $story->profile, + 'story:comment', + $text, [ - 'to_id' => $story->profile_id, - 'from_id' => $pid, + 'story_username' => $story->profile->username, + 'story_actor_username' => $request->user()->username, + 'story_id' => $story->id, + 'story_media_url' => url(Storage::url($story->path)), + 'caption' => $text, ], - [ - 'type' => 'story:comment', - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => false, - ] + $status->id ); - if ($story->local) { - $n = new Notification; - $n->profile_id = $dm->to_id; - $n->actor_id = $dm->from_id; - $n->item_id = $dm->id; - $n->item_type = DirectMessage::class; - $n->action = 'story:comment'; - $n->save(); - } else { + if (! $story->local) { StoryReplyDeliver::dispatch($story, $status)->onQueue('story'); } diff --git a/app/Http/Controllers/StoryComposeController.php b/app/Http/Controllers/StoryComposeController.php index 5d13dba3e..958a32b76 100644 --- a/app/Http/Controllers/StoryComposeController.php +++ b/app/Http/Controllers/StoryComposeController.php @@ -6,14 +6,12 @@ use App\Jobs\StoryPipeline\StoryDelete; use App\Jobs\StoryPipeline\StoryFanout; use App\Jobs\StoryPipeline\StoryReactionDeliver; use App\Jobs\StoryPipeline\StoryReplyDeliver; -use App\Models\Conversation; -use App\Models\DirectMessage; -use App\Models\Notification; use App\Models\Poll; use App\Models\PollVote; use App\Models\Report; use App\Models\Status; use App\Models\Story; +use App\Services\DirectMessageService; use App\Services\FollowerService; use App\Services\MediaPathService; use App\Services\StoryIndexService; @@ -522,43 +520,24 @@ class StoryComposeController extends Controller ? url(Storage::url($story->path)) : Storage::disk(config('filesystems.default'))->url($story->path); - $dm = new DirectMessage; - $dm->to_id = $story->profile_id; - $dm->from_id = $pid; - $dm->type = 'story:react'; - $dm->status_id = $status->id; - $dm->meta = json_encode([ - 'story_username' => $story->profile->username, - 'story_actor_username' => $request->user()->username, - 'story_id' => $story->id, - 'story_media_url' => $mediaUrl, - 'reaction' => $text, - ]); - $dm->save(); - - Conversation::updateOrInsert( + // Shows up in the conversation with the story author, who is + // notified when they are on this server + app(DirectMessageService::class)->storeStoryMessage( + $request->user()->profile, + $story->profile, + 'story:react', + $text, [ - 'to_id' => $story->profile_id, - 'from_id' => $pid, + 'story_username' => $story->profile->username, + 'story_actor_username' => $request->user()->username, + 'story_id' => $story->id, + 'story_media_url' => $mediaUrl, + 'reaction' => $text, ], - [ - 'type' => 'story:react', - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => false, - ] + $status->id ); - if ($story->local) { - // generate notification - $n = new Notification; - $n->profile_id = $dm->to_id; - $n->actor_id = $dm->from_id; - $n->item_id = $dm->id; - $n->item_type = DirectMessage::class; - $n->action = 'story:react'; - $n->save(); - } else { + if (! $story->local) { StoryReactionDeliver::dispatch($story, $status)->onQueue('story'); } @@ -605,43 +584,24 @@ class StoryComposeController extends Controller ? url(Storage::url($story->path)) : Storage::disk(config('filesystems.default'))->url($story->path); - $dm = new DirectMessage; - $dm->to_id = $story->profile_id; - $dm->from_id = $pid; - $dm->type = 'story:comment'; - $dm->status_id = $status->id; - $dm->meta = json_encode([ - 'story_username' => $story->profile->username, - 'story_actor_username' => $request->user()->username, - 'story_id' => $story->id, - 'story_media_url' => $mediaUrl, - 'caption' => $text, - ]); - $dm->save(); - - Conversation::updateOrInsert( + // Shows up in the conversation with the story author, who is + // notified when they are on this server + app(DirectMessageService::class)->storeStoryMessage( + $request->user()->profile, + $story->profile, + 'story:comment', + $text, [ - 'to_id' => $story->profile_id, - 'from_id' => $pid, + 'story_username' => $story->profile->username, + 'story_actor_username' => $request->user()->username, + 'story_id' => $story->id, + 'story_media_url' => $mediaUrl, + 'caption' => $text, ], - [ - 'type' => 'story:comment', - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => false, - ] + $status->id ); - if ($story->local) { - // generate notification - $n = new Notification; - $n->profile_id = $dm->to_id; - $n->actor_id = $dm->from_id; - $n->item_id = $dm->id; - $n->item_type = DirectMessage::class; - $n->action = 'story:comment'; - $n->save(); - } else { + if (! $story->local) { StoryReplyDeliver::dispatch($story, $status)->onQueue('story'); } diff --git a/app/Http/Requests/Status/StoreStatusEditRequest.php b/app/Http/Requests/Status/StoreStatusEditRequest.php index ef149cee9..1ff82bd29 100644 --- a/app/Http/Requests/Status/StoreStatusEditRequest.php +++ b/app/Http/Requests/Status/StoreStatusEditRequest.php @@ -55,6 +55,7 @@ class StoreStatusEditRequest extends FormRequest 'max:'.(int) config_cache('pixelfed.max_album_length'), function (string $attribute, mixed $value, Closure $fail) { Media::whereProfileId($this->user()->profile_id) + ->notInDirectMessage() ->where(function ($query) { return $query->whereNull('status_id') ->orWhere('status_id', '=', $this->route('id')); diff --git a/app/Http/Resources/AdminReport.php b/app/Http/Resources/AdminReport.php index 8fc2977c1..2c2328d46 100644 --- a/app/Http/Resources/AdminReport.php +++ b/app/Http/Resources/AdminReport.php @@ -2,9 +2,11 @@ namespace App\Http\Resources; +use App\Models\DmMessage; use App\Models\Status; use App\Models\Story; use App\Services\AccountService; +use App\Services\DirectMessagePayloadService; use App\Services\StatusService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -47,6 +49,13 @@ class AdminReport extends JsonResource $res['status'] = StatusService::get($this->object_id, false); } + if ($this->object_id && $this->object_type === DmMessage::class) { + $message = DmMessage::withTrashed()->with('media')->find($this->object_id); + if ($message) { + $res['direct_message'] = app(DirectMessagePayloadService::class)->message($message, (int) $this->profile_id); + } + } + if ($this->object_id && in_array($this->object_type, [Story::class, 'App\Story'])) { $story = Story::find($this->object_id); if ($story) { diff --git a/app/Jobs/DeletePipeline/DeleteAccountPipeline.php b/app/Jobs/DeletePipeline/DeleteAccountPipeline.php index 6da2fd55b..17e9e86e7 100644 --- a/app/Jobs/DeletePipeline/DeleteAccountPipeline.php +++ b/app/Jobs/DeletePipeline/DeleteAccountPipeline.php @@ -47,6 +47,7 @@ use App\Models\UserPronoun; use App\Models\UserSetting; use App\Services\AccountRevocationService; use App\Services\AccountService; +use App\Services\DirectMessageService; use App\Services\FollowerService; use App\Services\PublicTimelineService; use Illuminate\Bus\Queueable; @@ -169,6 +170,7 @@ class DeleteAccountPipeline implements ShouldQueue StatusHashtag::whereProfileId($id)->get()->each->delete(); DirectMessage::whereFromId($id)->orWhere('to_id', $id)->delete(); Conversation::whereFromId($id)->orWhere('to_id', $id)->delete(); + app(DirectMessageService::class)->purgeProfile($id); StatusArchived::whereProfileId($id)->delete(); UserPronoun::whereProfileId($id)->delete(); FollowRequest::whereFollowingId($id) diff --git a/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php b/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php index 3f840fa33..282658b30 100644 --- a/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php +++ b/app/Jobs/DeletePipeline/DeleteRemoteProfilePipeline.php @@ -23,6 +23,7 @@ use App\Models\Story; use App\Models\StoryView; use App\Models\UserFilter; use App\Services\AccountService; +use App\Services\DirectMessageService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -94,6 +95,7 @@ class DeleteRemoteProfilePipeline implements ShouldQueue // Delete DMs DirectMessage::whereFromId($pid)->orWhere('to_id', $pid)->delete(); Conversation::whereFromId($pid)->orWhere('to_id', $pid)->delete(); + app(DirectMessageService::class)->purgeProfile($pid); // Delete FollowRequests FollowRequest::whereFollowingId($pid) diff --git a/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php b/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php index 1ddb2210c..45c339520 100644 --- a/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php +++ b/app/Jobs/DeletePipeline/DeleteRemoteStatusPipeline.php @@ -15,6 +15,7 @@ use App\Models\Status; use App\Models\StatusHashtag; use App\Models\StatusView; use App\Services\Account\AccountStatService; +use App\Services\DirectMessageService; use App\Services\NetworkTimelineService; use App\Services\StatusService; use Illuminate\Bus\Queueable; @@ -80,6 +81,7 @@ class DeleteRemoteStatusPipeline implements ShouldQueue ->whereItemId($status->id) ->forceDelete(); DirectMessage::whereStatusId($status->id)->delete(); + app(DirectMessageService::class)->deleteByStatusId($status->id); Like::whereStatusId($status->id)->forceDelete(); MediaTag::whereStatusId($status->id)->delete(); $media = Media::whereStatusId($status->id)->get(); diff --git a/app/Jobs/Federation/DeliverDirectMessageActivity.php b/app/Jobs/Federation/DeliverDirectMessageActivity.php new file mode 100644 index 000000000..c70d54750 --- /dev/null +++ b/app/Jobs/Federation/DeliverDirectMessageActivity.php @@ -0,0 +1,117 @@ + + */ + public const RETRY_DELAYS = [30, 120, 600, 3600]; + + /** + * Statuses below 500 that are still worth another attempt. + */ + private const array RETRYABLE_STATUSES = [408, 425, 429]; + + public $timeout = 60; + + // One attempt plus the retries above + public $tries = 5; + + public $maxExceptions = 1; + + /** + * @param array $activity + */ + public function __construct( + protected int $fromProfileId, + protected string $inbox, + protected array $activity + ) {} + + public function inbox(): string + { + return $this->inbox; + } + + /** + * @return array + */ + public function activity(): array + { + return $this->activity; + } + + public function handle(): void + { + $from = Profile::find($this->fromProfileId); + + if (! $from || $from->domain !== null || $from->status !== null) { + return; + } + + if (! app()->environment('production')) { + return; + } + + try { + $response = ActivityPubDeliveryService::queue() + ->from($from) + ->to($this->inbox) + ->payload($this->activity) + ->deliver(); + } catch (InvalidDeliveryDestinationException|InvalidArgumentException) { + // Banned host, bad inbox URL, sender without keys: retrying cannot help + return; + } + + // Null means nothing reached the remote (connection failure, or the + // host is currently marked unavailable), which is worth retrying. + if ($response && ! self::shouldRetry($response->status())) { + return; + } + + $attempt = $this->attempts(); + + if ($attempt > count(self::RETRY_DELAYS)) { + Log::warning('DeliverDirectMessageActivity: giving up', [ + 'profile_id' => $from->id, + 'inbox' => $this->inbox, + 'type' => $this->activity['type'] ?? null, + 'id' => $this->activity['id'] ?? null, + 'status' => $response?->status(), + ]); + + return; + } + + $this->release(self::RETRY_DELAYS[$attempt - 1]); + } + + public static function shouldRetry(int $status): bool + { + return $status >= 500 || in_array($status, self::RETRYABLE_STATUSES, true); + } +} diff --git a/app/Jobs/StatusPipeline/RemoteStatusDelete.php b/app/Jobs/StatusPipeline/RemoteStatusDelete.php index 6e0c40f35..bc87520f6 100644 --- a/app/Jobs/StatusPipeline/RemoteStatusDelete.php +++ b/app/Jobs/StatusPipeline/RemoteStatusDelete.php @@ -20,6 +20,7 @@ use App\Models\StatusView; use App\Services\Account\AccountStatService; use App\Services\AccountService; use App\Services\CollectionService; +use App\Services\DirectMessageService; use App\Services\NotificationService; use App\Services\Status\ReplyCleanupService; use App\Services\StatusService; @@ -150,6 +151,7 @@ class RemoteStatusDelete implements ShouldBeUniqueUntilProcessing, ShouldQueue }); DirectMessage::whereIn('id', $dmIds)->delete(); } + app(DirectMessageService::class)->deleteByStatusId($status->id); Like::whereStatusId($status->id)->forceDelete(); $media = Media::whereStatusId($status->id)->get(); // Detach media from the status before dispatching deletion. status_id diff --git a/app/Jobs/StatusPipeline/StatusDelete.php b/app/Jobs/StatusPipeline/StatusDelete.php index eefd6cbdf..78cdfccd4 100644 --- a/app/Jobs/StatusPipeline/StatusDelete.php +++ b/app/Jobs/StatusPipeline/StatusDelete.php @@ -21,6 +21,7 @@ use App\Models\StatusHashtag; use App\Models\StatusView; use App\Services\ActivityPubDeliveryService; use App\Services\CollectionService; +use App\Services\DirectMessageService; use App\Services\FractalService; use App\Services\NotificationService; use App\Services\Status\ReplyCleanupService; @@ -150,6 +151,7 @@ class StatusDelete implements ShouldQueue }); DirectMessage::whereIn('id', $dmIds)->delete(); } + app(DirectMessageService::class)->deleteByStatusId($status->id); Like::whereStatusId($status->id)->delete(); $mediaTagIds = MediaTag::where('status_id', $status->id)->pluck('id'); diff --git a/app/Models/DmConversation.php b/app/Models/DmConversation.php new file mode 100644 index 000000000..5cf84a775 --- /dev/null +++ b/app/Models/DmConversation.php @@ -0,0 +1,103 @@ + 'datetime', + ]; + } + + /** + * A conversation is its set of participants. The same people always land + * in the same conversation, whichever server started the thread. + * + * @param array $profileIds + */ + public static function participantsHash(array $profileIds): string + { + $ids = array_values(array_unique(array_map('intval', $profileIds))); + sort($ids, SORT_NUMERIC); + + return hash('sha256', implode(':', $ids)); + } + + public static function dmHash(int $a, int $b): string + { + return self::participantsHash([$a, $b]); + } + + public function isGroup(): bool + { + return $this->type === self::TYPE_GROUP; + } + + public function participants(): HasMany + { + return $this->hasMany(DmConversationParticipant::class, 'conversation_id'); + } + + public function messages(): HasMany + { + return $this->hasMany(DmMessage::class, 'conversation_id'); + } + + public function lastMessage(): BelongsTo + { + return $this->belongsTo(DmMessage::class, 'last_message_id'); + } + + /** + * The ActivityPub `context` replies are sent with. + */ + public function contextUri(): string + { + return $this->context_uri ?: self::localContextUri($this->id); + } + + /** + * The OStatus `conversation` replies are sent with. Mastodon groups + * statuses by this value when it cannot resolve inReplyTo. + */ + public function conversationUri(): string + { + return $this->conversation_uri ?: $this->contextUri(); + } + + public static function localContextUri(int|string $id): string + { + return url('/i/dm/contexts/'.$id); + } +} diff --git a/app/Models/DmConversationParticipant.php b/app/Models/DmConversationParticipant.php new file mode 100644 index 000000000..b54ef5d51 --- /dev/null +++ b/app/Models/DmConversationParticipant.php @@ -0,0 +1,66 @@ + 'integer', + 'last_activity_at' => 'datetime', + 'muted_at' => 'datetime', + 'hidden_at' => 'datetime', + ]; + } + + public function conversation(): BelongsTo + { + return $this->belongsTo(DmConversation::class, 'conversation_id'); + } + + public function profile(): BelongsTo + { + return $this->belongsTo(Profile::class, 'profile_id'); + } + + public function isActive(): bool + { + return $this->state === self::STATE_ACTIVE; + } + + public function isRequest(): bool + { + return $this->state === self::STATE_REQUEST; + } + + public function hasLeft(): bool + { + return $this->state === self::STATE_LEFT; + } +} diff --git a/app/Models/DmMessage.php b/app/Models/DmMessage.php new file mode 100644 index 000000000..38f215cb4 --- /dev/null +++ b/app/Models/DmMessage.php @@ -0,0 +1,129 @@ + 'array', + 'meta' => 'array', + 'is_sensitive' => 'boolean', + 'edited_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::saving(function (DmMessage $message) { + if ($message->isDirty('ap_object_uri')) { + $message->ap_object_hash = $message->ap_object_uri + ? self::hashUri($message->ap_object_uri) + : null; + } + }); + } + + /** + * Object ids are looked up by hash so the unique index never depends on + * how long a remote server makes its ids. + */ + public static function hashUri(string $uri): string + { + return hash('sha256', $uri); + } + + protected function scopeWhereObjectUri(Builder $query, string $uri): Builder + { + return $query->where('ap_object_hash', self::hashUri($uri)); + } + + public static function localObjectUri(int|string $id): string + { + return url('/i/dm/messages/'.$id); + } + + /** + * The id this message is known by on the network. + */ + public function objectUri(): string + { + return $this->ap_object_uri ?: self::localObjectUri($this->id); + } + + public function conversation(): BelongsTo + { + return $this->belongsTo(DmConversation::class, 'conversation_id'); + } + + public function author(): BelongsTo + { + return $this->belongsTo(Profile::class, 'profile_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'in_reply_to_id'); + } + + public function media(): BelongsToMany + { + return $this->belongsToMany(Media::class, 'dm_message_media', 'message_id', 'media_id') + ->withPivot('position') + ->orderBy('dm_message_media.position'); + } +} diff --git a/app/Models/Media.php b/app/Models/Media.php index 964e5d81f..9c81ca3b1 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -117,6 +117,20 @@ class Media extends Model return $this->belongsTo(Status::class); } + /** + * Direct message media has no status_id, just like an upload that was + * never posted. This keeps it out of the queries that treat a null + * status_id as "unattached" (garbage collection, attaching to a post). + */ + public function scopeNotInDirectMessage($query) + { + return $query->whereNotExists(function ($sub) { + $sub->selectRaw('1') + ->from('dm_message_media') + ->whereColumn('dm_message_media.media_id', 'media.id'); + }); + } + public function profile() { return $this->belongsTo(Profile::class); diff --git a/app/Models/Report.php b/app/Models/Report.php index 6a1ffda80..45338ba55 100644 --- a/app/Models/Report.php +++ b/app/Models/Report.php @@ -79,6 +79,9 @@ class Report extends Model $column = 'id'; break; + case DmMessage::class: + return DmMessage::withTrashed()->find($this->object_id); + default: $class = Status::class; $column = 'id'; diff --git a/app/Services/DirectMessagePayloadService.php b/app/Services/DirectMessagePayloadService.php new file mode 100644 index 000000000..85e0a1653 --- /dev/null +++ b/app/Services/DirectMessagePayloadService.php @@ -0,0 +1,318 @@ + $rows The viewer's participant rows, with `conversation` loaded + * @return array> + */ + public function conversations(Collection $rows, int $viewerId): array + { + if ($rows->isEmpty()) { + return []; + } + + $conversationIds = $rows->pluck('conversation_id')->all(); + + $members = DmConversationParticipant::whereIn('conversation_id', $conversationIds) + ->orderBy('id') + ->get() + ->groupBy('conversation_id'); + + $lastMessages = DmMessage::with('media') + ->whereIn('id', $rows->map(fn ($row) => $row->conversation?->last_message_id)->filter()->all()) + ->get() + ->keyBy('id'); + + $blocked = $this->blockedIds($viewerId); + + return $rows + ->filter(fn ($row) => $row->conversation !== null) + ->map(function (DmConversationParticipant $row) use ($members, $lastMessages, $viewerId, $blocked) { + $last = $lastMessages->get($row->conversation->last_message_id); + + if ($last && in_array((int) $last->profile_id, $blocked, true)) { + $last = null; + } + + return $this->conversation( + $row->conversation, + $row, + $members->get($row->conversation_id, collect()), + $last, + $viewerId + ); + }) + ->values() + ->all(); + } + + /** + * @param Collection|null $members + * @return array + */ + public function conversation(DmConversation $conversation, DmConversationParticipant $viewer, ?Collection $members, ?DmMessage $last, int $viewerId): array + { + $members ??= DmConversationParticipant::where('conversation_id', $conversation->id)->orderBy('id')->get(); + + $participants = $members + ->filter(fn ($member) => (int) $member->profile_id !== $viewerId) + ->map(fn ($member) => AccountService::get($member->profile_id, true)) + ->filter() + ->values() + ->all(); + + return [ + 'id' => (string) $conversation->id, + 'type' => $conversation->type, + 'name' => $conversation->name, + 'participants' => $participants, + 'participant_count' => $members->count(), + 'state' => $viewer->state, + 'unread_count' => (int) $viewer->unread_count, + 'muted' => $viewer->muted_at !== null, + 'hidden' => $viewer->hidden_at !== null, + 'last_read_message_id' => $viewer->last_read_message_id ? (string) $viewer->last_read_message_id : null, + 'last_message' => $last ? $this->message($last, $viewerId) : null, + 'created_at' => $this->timestamp($conversation->created_at), + 'updated_at' => $this->timestamp($viewer->last_activity_at ?? $conversation->last_message_at ?? $conversation->updated_at), + ]; + } + + /* + |-------------------------------------------------------------------------- + | Messages + |-------------------------------------------------------------------------- + */ + + /** + * Text and media always travel together. `type` is only a hint for which + * bubble to draw. + * + * @return array + */ + public function message(DmMessage $message, int $viewerId): array + { + return [ + 'id' => (string) $message->id, + 'conversation_id' => (string) $message->conversation_id, + 'sender_id' => (string) $message->profile_id, + 'is_author' => (int) $message->profile_id === $viewerId, + 'type' => $message->type, + 'text' => $message->body, + 'media' => $this->media($message), + 'meta' => $message->meta, + 'sensitive' => (bool) $message->is_sensitive, + 'in_reply_to_id' => $message->in_reply_to_id ? (string) $message->in_reply_to_id : null, + 'edited_at' => $this->timestamp($message->edited_at), + 'created_at' => $this->timestamp($message->created_at), + ]; + } + + /** + * @param Collection $messages + * @return array> + */ + public function messages(Collection $messages, int $viewerId): array + { + return $messages->map(fn (DmMessage $message) => $this->message($message, $viewerId))->values()->all(); + } + + /** + * @return array> + */ + public function media(DmMessage $message): array + { + $media = $message->relationLoaded('media') ? $message->media : $message->media()->get(); + + if ($media->isEmpty()) { + return []; + } + + return FractalService::collection($media, new MediaTransformer); + } + + /** + * The shape the `/direct/thread` endpoints have always returned. + * + * @return array + */ + public function legacyMessage(DmMessage $message, int $viewerId, bool $hidden = false, ?int $otherLastReadId = null, ?int $viewerLastReadId = null): array + { + $media = $this->media($message); + $isAuthor = (int) $message->profile_id === $viewerId; + $readMarker = $isAuthor ? $otherLastReadId : $viewerLastReadId; + + return [ + 'id' => (string) $message->id, + 'hidden' => $hidden, + 'isAuthor' => $isAuthor, + 'type' => $message->type, + 'text' => $message->body, + 'media' => $media[0]['url'] ?? null, + 'carousel' => $media, + 'created_at' => $message->created_at->format('c'), + 'timeAgo' => $message->created_at->diffForHumans(null, null, true), + 'seen' => $readMarker !== null && $readMarker >= $message->id, + 'reportId' => (string) $message->id, + 'meta' => $message->meta, + ]; + } + + /* + |-------------------------------------------------------------------------- + | Mastodon compatible + |-------------------------------------------------------------------------- + */ + + /** + * @param Collection $members + * @return array|null + */ + public function mastodonConversation(DmConversation $conversation, DmConversationParticipant $viewer, Collection $members, ?DmMessage $last, int $viewerId): ?array + { + $accounts = $members + ->filter(fn ($member) => (int) $member->profile_id !== $viewerId) + ->map(fn ($member) => AccountService::getMastodon($member->profile_id, true)) + ->filter(fn ($account) => $account && isset($account['id'])) + ->values() + ->all(); + + if (empty($accounts) || ! $last) { + return null; + } + + return [ + 'id' => (string) $conversation->id, + 'unread' => $viewer->unread_count > 0, + 'accounts' => $accounts, + 'last_status' => $this->mastodonStatus($last, $accounts, $viewerId), + ]; + } + + /** + * Messages are not statuses any more, but Mastodon clients expect one as + * `last_status`, so this builds the entity from the message. + * + * @param array> $accounts + * @return array + */ + public function mastodonStatus(DmMessage $message, array $accounts, int $viewerId): array + { + $media = collect($this->media($message))->map(function (array $item) { + $mime = $item['mime'] ?? null; + + unset( + $item['optimized_url'], + $item['license'], + $item['is_nsfw'], + $item['orientation'], + $item['filter_name'], + $item['filter_class'], + $item['mime'], + $item['hls_manifest'] + ); + + $item['type'] = $mime ? strtolower(explode('/', $mime)[0]) : 'unknown'; + + return $item; + })->values()->all(); + + $uri = $message->objectUri(); + + return [ + 'id' => (string) $message->id, + 'uri' => $uri, + 'url' => $uri, + 'in_reply_to_id' => $message->in_reply_to_id ? (string) $message->in_reply_to_id : null, + 'in_reply_to_account_id' => null, + 'reblog' => null, + 'content' => self::renderHtml($message->body), + 'content_text' => $message->body, + 'created_at' => $this->timestamp($message->created_at), + 'edited_at' => $this->timestamp($message->edited_at), + 'emojis' => [], + 'replies_count' => 0, + 'reblogs_count' => 0, + 'favourites_count' => 0, + 'reblogged' => false, + 'favourited' => false, + 'muted' => false, + 'bookmarked' => false, + 'sensitive' => (bool) $message->is_sensitive, + 'spoiler_text' => '', + 'visibility' => 'direct', + 'application' => null, + 'language' => null, + 'mentions' => collect($accounts) + ->filter(fn ($account) => (string) $account['id'] !== (string) $message->profile_id) + ->map(fn ($account) => [ + 'id' => (string) $account['id'], + 'username' => $account['username'] ?? null, + 'acct' => $account['acct'] ?? null, + 'url' => $account['url'] ?? null, + ]) + ->values() + ->all(), + 'tags' => [], + 'card' => null, + 'poll' => null, + 'pf_type' => $message->type, + 'media_attachments' => $media, + 'account' => AccountService::getMastodon($message->profile_id, true), + ]; + } + + /* + |-------------------------------------------------------------------------- + | Helpers + |-------------------------------------------------------------------------- + */ + + /** + * Bodies are stored as plain text. Escape first, then link, so nothing a + * person typed can turn into markup here or on a remote server. + */ + public static function renderHtml(?string $body): string + { + if ($body === null || $body === '') { + return ''; + } + + $escaped = htmlspecialchars($body, ENT_NOQUOTES, 'UTF-8'); + + return '

'.nl2br(Autolink::create()->autolink($escaped), false).'

'; + } + + /** + * @return array + */ + public function blockedIds(int $viewerId): array + { + return array_map('intval', UserFilterService::blocks($viewerId) ?: []); + } + + protected function timestamp($value): ?string + { + if (! $value) { + return null; + } + + return str_replace('+00:00', 'Z', $value->format(DATE_RFC3339_EXTENDED)); + } +} diff --git a/app/Services/DirectMessageService.php b/app/Services/DirectMessageService.php new file mode 100644 index 000000000..24e483e2c --- /dev/null +++ b/app/Services/DirectMessageService.php @@ -0,0 +1,836 @@ +has_roles && ! UserRoleService::can('can-direct-message', $user->id)) { + return false; + } + + return true; + } + + /** + * New accounts have to wait before they can message anyone, unless the + * admin turned that off. + */ + public function canInitiateConversation(User $user): bool + { + if ($user->is_admin) { + return true; + } + + if ((bool) config_cache('instance.allow_new_account_dms')) { + return true; + } + + return ! $user->created_at->gt(now()->subHours(72)); + } + + /** + * Whether $sender may message $recipient at all. Blocks in either + * direction, domain blocks, and unreachable or disabled accounts all say + * no. Privacy settings do not: those turn the conversation into a request. + */ + public function canMessage(Profile $sender, Profile $recipient): bool + { + if ($sender->id === $recipient->id) { + return false; + } + + if ($recipient->status !== null || $sender->status !== null) { + return false; + } + + if ($this->isBlockedBy($recipient, $sender) || $this->isBlockedBy($sender, $recipient)) { + return false; + } + + if ($recipient->domain !== null) { + if ($sender->domain !== null) { + return false; + } + + if (! $recipient->inbox_url && ! $recipient->sharedInbox) { + return false; + } + + return (bool) config('federation.activitypub.enabled'); + } + + return (bool) $recipient->user_id; + } + + /** + * True when $owner blocks $other, either the account or its whole domain. + */ + public function isBlockedBy(Profile $owner, Profile $other): bool + { + if ($owner->domain !== null) { + return false; + } + + $blocks = UserFilterService::blocks($owner->id); + + if ($blocks && in_array($other->id, array_map('intval', $blocks), true)) { + return true; + } + + if ($other->domain && AccountService::blocksDomain($owner->id, $other->domain) === true) { + return true; + } + + return false; + } + + /** + * The state a recipient joins a conversation in. Someone who only takes + * messages from people they follow gets a request instead. + */ + public function initialState(Profile $recipient, Profile $sender): string + { + if ($recipient->domain !== null || $recipient->id === $sender->id) { + return DmConversationParticipant::STATE_ACTIVE; + } + + $acceptsEveryone = (bool) optional(optional($recipient->user)->settings)->public_dm; + + if ($acceptsEveryone && ! $recipient->is_private) { + return DmConversationParticipant::STATE_ACTIVE; + } + + return $recipient->follows($sender) + ? DmConversationParticipant::STATE_ACTIVE + : DmConversationParticipant::STATE_REQUEST; + } + + public function findDm(int $a, int $b): ?DmConversation + { + return DmConversation::where('participants_hash', DmConversation::dmHash($a, $b))->first(); + } + + public function findOrCreateDm(Profile $sender, Profile $recipient): DmConversation + { + return $this->findOrCreateConversation($sender, collect([$recipient])); + } + + /** + * Find the conversation these people share, or start it. + * + * @param Collection $others + * @param array{context_uri?: ?string, conversation_uri?: ?string, name?: ?string} $attributes + */ + public function findOrCreateConversation(Profile $creator, Collection $others, array $attributes = []): DmConversation + { + $profiles = collect([$creator])->concat($others)->unique('id')->values(); + + if ($profiles->count() < 2) { + throw new DirectMessageException('A conversation needs at least two participants.', 422); + } + + if ($profiles->count() > 2) { + if (! config('dm.groups.enabled')) { + throw new DirectMessageException('Group conversations are not enabled.', 422); + } + + if ($profiles->count() > (int) config('dm.groups.max_participants')) { + throw new DirectMessageException('Too many participants.', 422); + } + } + + $hash = DmConversation::participantsHash($profiles->pluck('id')->all()); + + $conversation = DmConversation::where('participants_hash', $hash)->first(); + + if ($conversation) { + return $conversation; + } + + try { + return DB::transaction(function () use ($creator, $profiles, $hash, $attributes) { + $conversation = new DmConversation; + $conversation->id = SnowflakeService::next(); + $conversation->type = $profiles->count() > 2 ? DmConversation::TYPE_GROUP : DmConversation::TYPE_DM; + $conversation->participants_hash = $hash; + $conversation->name = $attributes['name'] ?? null; + $conversation->context_uri = ($attributes['context_uri'] ?? null) + ?: DmConversation::localContextUri($conversation->id); + $conversation->conversation_uri = $attributes['conversation_uri'] ?? null; + $conversation->created_by_profile_id = $creator->id; + $conversation->save(); + + $now = now(); + + DmConversationParticipant::insert($profiles->map(fn (Profile $profile) => [ + 'conversation_id' => $conversation->id, + 'profile_id' => $profile->id, + 'state' => $this->initialState($profile, $creator), + 'created_at' => $now, + 'updated_at' => $now, + ])->all()); + + return $conversation; + }); + } catch (QueryException $e) { + // Lost a race to create the same conversation + $conversation = DmConversation::where('participants_hash', $hash)->first(); + + if ($conversation) { + return $conversation; + } + + throw $e; + } + } + + public function participant(DmConversation|int $conversation, int $profileId): ?DmConversationParticipant + { + $id = $conversation instanceof DmConversation ? $conversation->id : $conversation; + + return DmConversationParticipant::where('conversation_id', $id) + ->where('profile_id', $profileId) + ->first(); + } + + /** + * The conversation, but only for someone who is in it and has not left. + * + * @return array{0: DmConversation, 1: DmConversationParticipant}|null + */ + public function conversationFor(int|string $conversationId, int $profileId): ?array + { + $participant = DmConversationParticipant::where('conversation_id', $conversationId) + ->where('profile_id', $profileId) + ->where('state', '!=', DmConversationParticipant::STATE_LEFT) + ->first(); + + if (! $participant) { + return null; + } + + $conversation = DmConversation::find($conversationId); + + return $conversation ? [$conversation, $participant] : null; + } + + /** + * Profile ids of everyone in the conversation. + * + * @return array + */ + public function participantIds(DmConversation|int $conversation): array + { + $id = $conversation instanceof DmConversation ? $conversation->id : $conversation; + + return DmConversationParticipant::where('conversation_id', $id) + ->orderBy('id') + ->pluck('profile_id') + ->map(fn ($id) => (int) $id) + ->all(); + } + + /** + * Remote servers are the source of truth for the thread identifiers, so + * the newest inbound values win and are what our replies carry. + */ + public function adoptContext(DmConversation $conversation, ?string $contextUri, ?string $conversationUri): void + { + $dirty = false; + + if ($contextUri && strlen($contextUri) <= 1024 && $conversation->context_uri !== $contextUri) { + $conversation->context_uri = $contextUri; + $dirty = true; + } + + if ($conversationUri && strlen($conversationUri) <= 1024 && $conversation->conversation_uri !== $conversationUri) { + $conversation->conversation_uri = $conversationUri; + $dirty = true; + } + + if ($dirty) { + $conversation->save(); + } + } + + /** + * Send a message from a local profile. + * + * @param array{body?: ?string, type?: ?string, media?: ?Collection, in_reply_to_id?: ?int, is_sensitive?: bool} $data + */ + public function sendMessage(DmConversation $conversation, Profile $sender, array $data): DmMessage + { + $participant = $this->participant($conversation, $sender->id); + + if (! $participant || $participant->hasLeft()) { + throw new DirectMessageException('You are not part of this conversation.', 403); + } + + $body = isset($data['body']) ? trim((string) $data['body']) : ''; + $media = $data['media'] ?? collect(); + + if ($body === '' && $media->isEmpty()) { + throw new DirectMessageException('A message needs text or media.', 422); + } + + $others = Profile::whereIn('id', array_diff($this->participantIds($conversation), [$sender->id]))->get(); + + if (! $conversation->isGroup()) { + $recipient = $others->first(); + + if (! $recipient || ! $this->canMessage($sender, $recipient)) { + throw new DirectMessageException('You cannot message this account.', 403); + } + + $this->enforceRequestLimit($conversation, $sender, $recipient); + } elseif ($others->filter(fn (Profile $other) => $other->status === null)->isEmpty()) { + throw new DirectMessageException('Nobody in this conversation can be reached.', 403); + } + + if (! empty($data['in_reply_to_id'])) { + $parentExists = DmMessage::where('conversation_id', $conversation->id) + ->where('id', $data['in_reply_to_id']) + ->exists(); + + if (! $parentExists) { + throw new DirectMessageException('Invalid in_reply_to_id.', 422); + } + } + + $message = $this->storeMessage($conversation, $sender, [ + 'body' => $body === '' ? null : $body, + 'type' => $data['type'] ?? null, + 'media' => $media, + 'in_reply_to_id' => $data['in_reply_to_id'] ?? null, + 'is_sensitive' => (bool) ($data['is_sensitive'] ?? false), + ]); + + $this->federateCreate($message, $conversation, $sender, $others); + + return $message; + } + + /** + * Until a request is accepted the sender only gets a few messages in. + */ + protected function enforceRequestLimit(DmConversation $conversation, Profile $sender, Profile $recipient): void + { + if ($recipient->domain !== null) { + return; + } + + $state = $this->participant($conversation, $recipient->id); + + if (! $state || ! $state->isRequest()) { + return; + } + + $sent = DmMessage::where('conversation_id', $conversation->id) + ->where('profile_id', $sender->id) + ->count(); + + if ($sent >= (int) config('dm.requests.sender_limit')) { + throw new DirectMessageException('You can send more messages once your request is accepted.', 403); + } + } + + /** + * Write a message and bring the conversation up to date. Shared by local + * sends, inbound federation, story replies and the backfill. + * + * @param array{ + * body?: ?string, + * type?: ?string, + * media?: ?Collection, + * meta?: ?array, + * entities?: ?array, + * ap_object_uri?: ?string, + * in_reply_to_id?: ?int, + * status_id?: ?int, + * is_sensitive?: bool, + * notify?: bool, + * notification_action?: string, + * } $data + */ + public function storeMessage(DmConversation $conversation, Profile $sender, array $data): DmMessage + { + $media = $data['media'] ?? collect(); + $body = $data['body'] ?? null; + + [$type, $meta] = $this->resolveType($data['type'] ?? null, $body, $media, $data['meta'] ?? null); + + $message = DB::transaction(function () use ($conversation, $sender, $data, $media, $body, $type, $meta) { + $message = new DmMessage; + $message->id = SnowflakeService::next(); + $message->conversation_id = $conversation->id; + $message->profile_id = $sender->id; + $message->type = $type; + $message->body = $body; + $message->meta = $meta; + $message->entities = $data['entities'] ?? null; + $message->is_sensitive = (bool) ($data['is_sensitive'] ?? false); + $message->in_reply_to_id = $data['in_reply_to_id'] ?? null; + $message->status_id = $data['status_id'] ?? null; + $message->ap_object_uri = ($data['ap_object_uri'] ?? null) ?: DmMessage::localObjectUri($message->id); + $message->save(); + + $position = 0; + foreach ($media as $item) { + DB::table('dm_message_media')->insert([ + 'message_id' => $message->id, + 'media_id' => $item->id, + 'position' => $position++, + ]); + } + + $conversation->last_message_id = $message->id; + $conversation->last_message_at = $message->created_at; + $conversation->save(); + + return $message; + }); + + $this->fanOut($conversation, $message, $sender, [ + 'notify' => $data['notify'] ?? true, + 'action' => $data['notification_action'] ?? 'dm', + ]); + + return $message; + } + + /** + * Update every participant's view of the conversation. A recipient who + * blocks the sender is skipped without anyone being told: they get no + * unread count, no notification, and the message is filtered out when + * they read the conversation. + * + * @param array{notify: bool, action: string} $options + */ + protected function fanOut(DmConversation $conversation, DmMessage $message, Profile $sender, array $options): void + { + $now = $message->created_at ?? now(); + + $participants = DmConversationParticipant::where('conversation_id', $conversation->id)->get(); + $profiles = Profile::whereIn('id', $participants->pluck('profile_id'))->get()->keyBy('id'); + + foreach ($participants as $participant) { + $profile = $profiles->get($participant->profile_id); + + if (! $profile) { + continue; + } + + if ($profile->id === $sender->id) { + $participant->last_read_message_id = $message->id; + $participant->last_activity_at = $now; + $participant->unread_count = 0; + $participant->hidden_at = null; + + // Replying to a request accepts it + if ($participant->isRequest()) { + $participant->state = DmConversationParticipant::STATE_ACTIVE; + } + + $participant->save(); + + continue; + } + + if ($profile->domain !== null || $participant->hasLeft()) { + continue; + } + + if ($this->isBlockedBy($profile, $sender)) { + continue; + } + + $participant->unread_count = $participant->unread_count + 1; + $participant->last_activity_at = $now; + $participant->save(); + + if ( + $options['notify'] && + $participant->isActive() && + ! $participant->muted_at && + ! $participant->hidden_at + ) { + $this->notify($profile, $sender, $message, $options['action']); + } + } + } + + protected function notify(Profile $recipient, Profile $sender, DmMessage $message, string $action): void + { + NotificationService::createNotification( + $recipient->id, + $sender->id, + $action, + $message->id, + DmMessage::class + ); + + if (! NotificationAppGatewayService::enabled()) { + return; + } + + if (! PushNotificationService::check('mention', $recipient->id)) { + return; + } + + $user = User::whereProfileId($recipient->id)->first(); + + if ($user && $user->expo_token && $user->notify_enabled) { + MentionPushNotifyPipeline::dispatch($user->expo_token, $sender->username)->onQueue('pushnotify'); + } + } + + /** + * Legacy clients pick a renderer from `type`, newer ones read `text` and + * `media` and treat it as a hint. + * + * @return array{0: string, 1: ?array} + */ + protected function resolveType(?string $requested, ?string $body, Collection $media, ?array $meta): array + { + if (in_array($requested, [DmMessage::TYPE_STORY_REACT, DmMessage::TYPE_STORY_COMMENT], true)) { + return [$requested, $meta]; + } + + if ($media->isNotEmpty()) { + $photos = $media->filter(fn ($m) => str_starts_with((string) $m->mime, 'image/'))->count(); + $videos = $media->filter(fn ($m) => str_starts_with((string) $m->mime, 'video/'))->count(); + + $type = match (true) { + $photos > 0 && $videos > 0 => DmMessage::TYPE_MEDIA, + $videos > 1 => DmMessage::TYPE_VIDEOS, + $videos === 1 => DmMessage::TYPE_VIDEO, + $photos > 1 => DmMessage::TYPE_PHOTOS, + default => DmMessage::TYPE_PHOTO, + }; + + return [$type, $meta]; + } + + if ($body && filter_var($body, FILTER_VALIDATE_URL) && Helpers::validateUrl($body)) { + $host = parse_url($body, PHP_URL_HOST); + + return [DmMessage::TYPE_LINK, array_merge($meta ?? [], [ + 'domain' => $host, + 'local' => $host === parse_url(config('app.url'), PHP_URL_HOST), + ])]; + } + + if ($requested === DmMessage::TYPE_EMOJI) { + return [DmMessage::TYPE_EMOJI, $meta]; + } + + return [DmMessage::TYPE_TEXT, $meta]; + } + + /** + * Story reactions and replies show up in the conversation between the + * viewer and the story author. Their federation is handled by the story + * pipeline, which uses $statusId as the ActivityPub object. + */ + public function storeStoryMessage(Profile $sender, Profile $storyAuthor, string $type, ?string $text, array $meta, ?int $statusId = null, ?string $objectUri = null): DmMessage + { + $conversation = $this->findOrCreateDm($sender, $storyAuthor); + + // A story reply is only possible between people who already follow + // each other's stories, so it never sits in requests + DmConversationParticipant::where('conversation_id', $conversation->id) + ->where('state', DmConversationParticipant::STATE_REQUEST) + ->update(['state' => DmConversationParticipant::STATE_ACTIVE]); + + return $this->storeMessage($conversation, $sender, [ + 'type' => $type, + 'body' => $text, + 'meta' => $meta, + 'status_id' => $statusId, + 'ap_object_uri' => $objectUri, + 'notify' => $storyAuthor->domain === null, + 'notification_action' => $type, + ]); + } + + /** + * Uploaded media the sender may attach: their own, not on a post, not + * already in another message. + * + * @param array $ids + * @return Collection + */ + public function attachableMedia(User $user, array $ids): Collection + { + $ids = array_values(array_unique(array_map('intval', $ids))); + + if (empty($ids)) { + return collect(); + } + + if (count($ids) > (int) config('dm.max_media')) { + throw new DirectMessageException('Too many attachments.', 422); + } + + $media = Media::whereIn('id', $ids) + ->where('user_id', $user->id) + ->where('profile_id', $user->profile_id) + ->whereNull('status_id') + ->notInDirectMessage() + ->get() + ->keyBy('id'); + + if ($media->count() !== count($ids)) { + throw new DirectMessageException('Invalid media_ids.', 422); + } + + return collect($ids)->map(fn ($id) => $media->get($id))->values(); + } + + public static function isMessageMedia(int|string $mediaId): bool + { + return DB::table('dm_message_media')->where('media_id', $mediaId)->exists(); + } + + /** + * Remove a message for everyone. Local authors also tell the other + * servers in the conversation. + */ + public function deleteMessage(DmMessage $message, bool $federate = true): void + { + $conversation = DmConversation::find($message->conversation_id); + $sender = Profile::find($message->profile_id); + + // Story reactions and replies are federated by the story pipeline + $isStory = in_array($message->type, [DmMessage::TYPE_STORY_REACT, DmMessage::TYPE_STORY_COMMENT], true); + + if ($federate && ! $isStory && $conversation && $sender && $sender->domain === null) { + $this->federateDelete($message, $conversation, $sender); + } + + $mediaIds = DB::table('dm_message_media')->where('message_id', $message->id)->pluck('media_id'); + + if ($mediaIds->isNotEmpty()) { + DB::table('dm_message_media')->where('message_id', $message->id)->delete(); + + // Backfilled legacy media still belongs to its direct status and + // goes away with it, everything else is ours to remove + Media::whereIn('id', $mediaIds)->whereNull('status_id')->get() + ->each(fn (Media $media) => MediaStorageService::delete($media, true)); + } + + Notification::where('item_type', DmMessage::class) + ->where('item_id', $message->id) + ->get() + ->each(function (Notification $notification) { + NotificationService::del($notification->profile_id, $notification->id); + $notification->forceDelete(); + }); + + $message->delete(); + + if ($conversation) { + $this->refreshAfterDelete($conversation, $message); + } + } + + protected function refreshAfterDelete(DmConversation $conversation, DmMessage $deleted): void + { + if ((int) $conversation->last_message_id === (int) $deleted->id) { + $latest = DmMessage::where('conversation_id', $conversation->id)->orderByDesc('id')->first(); + + $conversation->last_message_id = $latest?->id; + $conversation->last_message_at = $latest?->created_at; + $conversation->save(); + } + + DmConversationParticipant::where('conversation_id', $conversation->id) + ->where('profile_id', '!=', $deleted->profile_id) + ->where('unread_count', '>', 0) + ->where(function ($query) use ($deleted) { + $query->whereNull('last_read_message_id') + ->orWhere('last_read_message_id', '<', $deleted->id); + }) + ->decrement('unread_count'); + } + + /** + * Story reactions and legacy rows hang off a direct status. When that + * status is deleted the message goes with it. + */ + public function deleteByStatusId(int|string $statusId): void + { + DmMessage::where('status_id', $statusId)->get() + ->each(fn (DmMessage $message) => $this->deleteMessage($message, false)); + } + + /** + * Account deletion: drop what the profile wrote and take it out of its + * conversations. Conversations nobody else is left in are removed. + */ + public function purgeProfile(int $profileId): void + { + DmMessage::where('profile_id', $profileId) + ->chunkById(200, function ($messages) { + foreach ($messages as $message) { + $this->deleteMessage($message, false); + } + }); + + $conversationIds = DmConversationParticipant::where('profile_id', $profileId)->pluck('conversation_id'); + + foreach ($conversationIds->chunk(200) as $chunk) { + $direct = DmConversation::whereIn('id', $chunk) + ->where('type', DmConversation::TYPE_DM) + ->pluck('id'); + + if ($direct->isNotEmpty()) { + DmMessage::whereIn('conversation_id', $direct) + ->chunkById(200, function ($messages) { + foreach ($messages as $message) { + $this->deleteMessage($message, false); + } + }); + + DmConversationParticipant::whereIn('conversation_id', $direct)->delete(); + DmConversation::whereIn('id', $direct)->delete(); + } + } + + DmConversationParticipant::where('profile_id', $profileId) + ->update(['state' => DmConversationParticipant::STATE_LEFT, 'unread_count' => 0]); + } + + public function markRead(DmConversationParticipant $participant, ?int $upToMessageId = null): void + { + $query = DmMessage::where('conversation_id', $participant->conversation_id); + + $latestId = $upToMessageId + ? (clone $query)->where('id', '<=', $upToMessageId)->max('id') + : (clone $query)->max('id'); + + if (! $latestId) { + return; + } + + if ($participant->last_read_message_id && $participant->last_read_message_id >= $latestId) { + return; + } + + $participant->last_read_message_id = $latestId; + $participant->unread_count = (clone $query) + ->where('id', '>', $latestId) + ->where('profile_id', '!=', $participant->profile_id) + ->count(); + $participant->save(); + } + + public function accept(DmConversationParticipant $participant): void + { + if ($participant->isRequest()) { + $participant->state = DmConversationParticipant::STATE_ACTIVE; + $participant->save(); + } + } + + public function setMuted(DmConversationParticipant $participant, bool $muted): void + { + $participant->muted_at = $muted ? ($participant->muted_at ?? now()) : null; + $participant->save(); + } + + public function setHidden(DmConversationParticipant $participant, bool $hidden): void + { + $participant->hidden_at = $hidden ? ($participant->hidden_at ?? now()) : null; + $participant->save(); + } + + /** + * Leaving only changes what this person sees. The participant set is the + * conversation's identity on the network, so it never shrinks. + */ + public function leave(DmConversation $conversation, DmConversationParticipant $participant): void + { + if (! $conversation->isGroup()) { + throw new DirectMessageException('Only group conversations can be left.', 422); + } + + $participant->state = DmConversationParticipant::STATE_LEFT; + $participant->unread_count = 0; + $participant->save(); + } + + /** + * @param Collection $others + */ + protected function federateCreate(DmMessage $message, DmConversation $conversation, Profile $sender, Collection $others): void + { + $inboxes = $this->remoteInboxes($others); + + if (empty($inboxes) || ! config('federation.activitypub.enabled')) { + return; + } + + $activity = app(DirectMessageActivityBuilder::class)->buildCreate($message, $conversation, $sender, $others); + + foreach ($inboxes as $inbox) { + DeliverDirectMessageActivity::dispatch($sender->id, $inbox, $activity)->onQueue('high'); + } + } + + protected function federateDelete(DmMessage $message, DmConversation $conversation, Profile $sender): void + { + if (! config('federation.activitypub.enabled')) { + return; + } + + $others = Profile::whereIn('id', array_diff($this->participantIds($conversation), [$sender->id]))->get(); + $inboxes = $this->remoteInboxes($others); + + if (empty($inboxes)) { + return; + } + + $activity = app(DirectMessageActivityBuilder::class)->buildDelete($message, $sender, $others); + + foreach ($inboxes as $inbox) { + DeliverDirectMessageActivity::dispatch($sender->id, $inbox, $activity)->onQueue('high'); + } + } + + /** + * One delivery per server: people behind the same shared inbox get a + * single copy. + * + * @param Collection $profiles + * @return array + */ + public function remoteInboxes(Collection $profiles): array + { + return $profiles + ->filter(fn (Profile $profile) => $profile->domain !== null && $profile->status === null) + ->map(fn (Profile $profile) => $profile->sharedInbox ?: $profile->inbox_url) + ->filter() + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Transformer/Api/NotificationTransformer.php b/app/Transformer/Api/NotificationTransformer.php index 260e5823e..cace98ef3 100644 --- a/app/Transformer/Api/NotificationTransformer.php +++ b/app/Transformer/Api/NotificationTransformer.php @@ -2,6 +2,7 @@ namespace App\Transformer\Api; +use App\Models\DmMessage; use App\Models\MediaTag; use App\Models\ModLog; use App\Models\Notification; @@ -34,6 +35,17 @@ class NotificationTransformer extends Fractal\TransformerAbstract $res['status'] = StatusService::get($n->item_id, false); } + // Lets a client open the right conversation from the notification + if ($n->item_id && $n->item_type == DmMessage::class) { + $message = DmMessage::find($n->item_id); + if ($message) { + $res['direct'] = [ + 'conversation_id' => (string) $message->conversation_id, + 'message_id' => (string) $message->id, + ]; + } + } + if ($n->item_id && $n->item_type == ModLog::class) { $ml = $n->item; if ($ml && $ml->object_uid) { diff --git a/app/Util/ActivityPub/Inbox/HandlesCreates.php b/app/Util/ActivityPub/Inbox/HandlesCreates.php index c797ebddb..a489556ef 100644 --- a/app/Util/ActivityPub/Inbox/HandlesCreates.php +++ b/app/Util/ActivityPub/Inbox/HandlesCreates.php @@ -2,26 +2,15 @@ namespace App\Util\ActivityPub\Inbox; -use App\Jobs\PushNotificationPipeline\MentionPushNotifyPipeline; +use App\Federation\Handlers\DirectMessageHandler; +use App\Federation\Validators\DirectMessageValidator; use App\Jobs\StatusPipeline\RemoteReplyResolvePipeline; -use App\Models\Conversation; -use App\Models\DirectMessage; -use App\Models\Media; -use App\Models\Notification; use App\Models\PollVote; use App\Models\Profile; use App\Models\Status; -use App\Models\User; -use App\Models\UserFilter; use App\Services\FollowerService; -use App\Services\NotificationAppGatewayService; -use App\Services\NotificationService; use App\Services\PollService; -use App\Services\PushNotificationService; -use App\Services\SanitizeService; use App\Util\ActivityPub\Helpers; -use Illuminate\Support\Arr; -use Illuminate\Support\Str; trait HandlesCreates { @@ -42,9 +31,6 @@ trait HandlesCreates return; } - $to = $this->normalizeRecipients($activity['to'] ?? []); - $cc = $this->normalizeRecipients($activity['cc'] ?? []); - if ($activity['type'] == 'Question') { return; } @@ -63,8 +49,12 @@ trait HandlesCreates return; } - if ($this->isDirectMessage($to, $cc)) { - $this->handleDirectMessage(); + // Anything addressed only to people is a direct message, whether it + // names one of them or several. It stops here even when it cannot be + // stored: the paths below file every non-public Note as + // followers-only, which would show it to the sender's followers. + if (DirectMessageValidator::isDirect($activity, $actor)) { + $this->handleDirectMessage($actor); return; } @@ -311,62 +301,26 @@ trait HandlesCreates PollService::del($status->id); } - public function handleDirectMessage(): void + /** + * Hand a direct Note to the direct message handler. + */ + public function handleDirectMessage(Profile $actor): void { - $activity = $this->payload['object']; - $to = $this->normalizeRecipients($activity['to'] ?? []); + $object = $this->payload['object']; - $actor = $this->validateAndFetchActor($this->payload['actor']); - $profile = Profile::whereNull('domain') - ->whereUsername(Arr::last(explode('/', $to[0]))) - ->firstOrFail(); + $id = Helpers::pluckval($object['id'] ?? null); - if (! $actor || in_array($actor->id, $profile->blockedIds()->toArray())) { + if (! is_string($id) || ! Helpers::validateUrl($id)) { return; } - if ($this->isDomainBlocked($profile->id, $actor->domain)) { + // Only the author can deliver their own message. A direct message is + // never fetched to double check: it is not readable by anyone else. + if (! $this->deliveredObjectIsTrusted($object, $actor, $id)) { return; } - $msgText = $this->sanitizeDirectMessageContent($activity['content'], $profile->username); - $hidden = $this->determineDirectMessageVisibility($profile, $actor); - - $status = new Status; - $status->profile_id = $actor->id; - $status->caption = $msgText; - $status->visibility = 'direct'; - $status->scope = 'direct'; - $status->url = $activity['id']; - $status->uri = $activity['id']; - $status->object_url = $activity['id']; - $status->in_reply_to_profile_id = $profile->id; - $status->save(); - - $dm = new DirectMessage; - $dm->to_id = $profile->id; - $dm->from_id = $actor->id; - $dm->status_id = $status->id; - $dm->is_hidden = $hidden; - $dm->type = 'text'; - $dm->save(); - - Conversation::updateOrInsert( - [ - 'to_id' => $profile->id, - 'from_id' => $actor->id, - ], - [ - 'type' => 'text', - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => $hidden, - ] - ); - - $this->processDirectMessageAttachments($activity, $status, $dm); - $this->processDirectMessageLink($msgText, $dm); - $this->notifyDirectMessageRecipient($profile, $actor, $dm, $hidden); + app(DirectMessageHandler::class)->handleCreate($object, $actor); } /** @@ -398,154 +352,4 @@ trait HandlesCreates return false; } - - /** - * Normalize recipients to always be an array (JSON-LD allows single strings). - */ - protected function normalizeRecipients(mixed $recipients): array - { - if (is_string($recipients)) { - return [$recipients]; - } - - return is_array($recipients) ? $recipients : []; - } - - /** - * Determine if the activity is a direct message (single local recipient, no cc). - */ - protected function isDirectMessage(array $to, array $cc): bool - { - return is_array($to) && - is_array($cc) && - count($to) === 1 && - count($cc) === 0 && - parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app'); - } - - /** - * Sanitize DM content and strip leading @mention of the recipient. - */ - protected function sanitizeDirectMessageContent(string $content, string $username): string - { - $msg = app(SanitizeService::class)->html($content); - $msgText = strip_tags($msg); - - if (Str::startsWith($msgText, '@'.$username)) { - $len = strlen('@'.$username); - $msgText = substr($msgText, $len + 1); - } - - return $msgText; - } - - /** - * Determine if a DM should be hidden based on recipient privacy settings. - */ - protected function determineDirectMessageVisibility(Profile $profile, Profile $actor): bool - { - if ($profile->user->settings->public_dm == false || $profile->is_private) { - return $profile->follows($actor) !== true; - } - - return false; - } - - /** - * Process attachments on a direct message. - */ - protected function processDirectMessageAttachments(array $activity, Status $status, DirectMessage $dm): void - { - if (! count($activity['attachment'] ?? [])) { - return; - } - - $photos = 0; - $videos = 0; - $allowed = explode(',', config_cache('pixelfed.media_types')); - $attachments = array_slice($activity['attachment'], 0, config_cache('pixelfed.max_album_length')); - - foreach ($attachments as $a) { - $type = $a['mediaType']; - $url = $a['url']; - - if (! in_array($type, $allowed) || ! Helpers::validateUrl($url)) { - continue; - } - - $media = new Media; - $media->remote_media = true; - $media->status_id = $status->id; - $media->profile_id = $status->profile_id; - $media->user_id = null; - $media->media_path = $url; - $media->remote_url = $url; - $media->mime = $type; - $media->save(); - - if (explode('/', $type)[0] == 'image') { - $photos++; - } - if (explode('/', $type)[0] == 'video') { - $videos++; - } - } - - if ($photos && $videos === 0) { - $dm->type = $photos === 1 ? 'photo' : 'photos'; - $dm->save(); - } - if ($videos && $photos === 0) { - $dm->type = $videos === 1 ? 'video' : 'videos'; - $dm->save(); - } - } - - /** - * If the DM text is a valid URL, mark the DM type as 'link'. - */ - protected function processDirectMessageLink(string $msgText, DirectMessage $dm): void - { - if (! filter_var($msgText, FILTER_VALIDATE_URL)) { - return; - } - - if (! Helpers::validateUrl($msgText)) { - return; - } - - $dm->type = 'link'; - $dm->meta = [ - 'domain' => parse_url($msgText, PHP_URL_HOST), - 'local' => parse_url($msgText, PHP_URL_HOST) == parse_url(config('app.url'), PHP_URL_HOST), - ]; - $dm->save(); - } - - /** - * Send notification to DM recipient if applicable. - */ - protected function notifyDirectMessageRecipient(Profile $profile, Profile $actor, DirectMessage $dm, bool $hidden): void - { - $isMuted = UserFilter::whereUserId($profile->id) - ->whereFilterableId($actor->id) - ->whereFilterableType(Profile::class) - ->whereFilterType('dm.mute') - ->exists(); - - if ($profile->domain != null || $hidden || $isMuted) { - return; - } - - NotificationService::createNotification($profile->id, $actor->id, 'dm', $dm->id, DirectMessage::class); - - if (NotificationAppGatewayService::enabled()) { - if (PushNotificationService::check('mention', $profile->id)) { - $user = User::whereProfileId($profile->id)->first(); - if ($user && $user->expo_token && $user->notify_enabled) { - MentionPushNotifyPipeline::dispatch($user->expo_token, $actor->username)->onQueue('pushnotify'); - } - } - } - } } diff --git a/app/Util/ActivityPub/Inbox/HandlesDeletes.php b/app/Util/ActivityPub/Inbox/HandlesDeletes.php index 5f8e37e65..d2a9ad006 100644 --- a/app/Util/ActivityPub/Inbox/HandlesDeletes.php +++ b/app/Util/ActivityPub/Inbox/HandlesDeletes.php @@ -2,6 +2,7 @@ namespace App\Util\ActivityPub\Inbox; +use App\Federation\Handlers\DirectMessageHandler; use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline; use App\Jobs\HomeFeedPipeline\FeedRemoveRemotePipeline; use App\Jobs\StatusPipeline\RemoteStatusDelete; @@ -108,6 +109,10 @@ trait HandlesDeletes return; } + if (app(DirectMessageHandler::class)->handleDelete($profile, $objectId)) { + return; + } + // FEP-044f: if this post was an approved quote, its stamp goes with it QuoteService::forgetQuote($profile->id, $objectId); diff --git a/app/Util/ActivityPub/Inbox/HandlesStories.php b/app/Util/ActivityPub/Inbox/HandlesStories.php index d437b4ed7..234864d5a 100644 --- a/app/Util/ActivityPub/Inbox/HandlesStories.php +++ b/app/Util/ActivityPub/Inbox/HandlesStories.php @@ -3,13 +3,11 @@ namespace App\Util\ActivityPub\Inbox; use App\Jobs\StoryPipeline\StoryFetch; -use App\Models\Conversation; -use App\Models\DirectMessage; use App\Models\Status; use App\Models\Story; use App\Models\StoryView; +use App\Services\DirectMessageService; use App\Services\FollowerService; -use App\Services\NotificationService; use App\Services\SanitizeService; use App\Services\StoryIndexService; use App\Util\ActivityPub\Helpers; @@ -190,33 +188,20 @@ trait HandlesStories ]); $status->save(); - $dm = new DirectMessage; - $dm->to_id = $story->profile_id; - $dm->from_id = $actorProfile->id; - $dm->type = $dmType; - $dm->status_id = $status->id; - $dm->meta = json_encode([ - 'story_username' => $targetProfile->username, - 'story_actor_username' => $actorProfile->username, - 'story_id' => $story->id, - 'story_media_url' => url(Storage::url($story->path)), - $metaKey => $text, - ]); - $dm->save(); - - Conversation::updateOrInsert( + app(DirectMessageService::class)->storeStoryMessage( + $actorProfile, + $targetProfile, + $dmType, + $text, [ - 'to_id' => $story->profile_id, - 'from_id' => $actorProfile->id, + 'story_username' => $targetProfile->username, + 'story_actor_username' => $actorProfile->username, + 'story_id' => $story->id, + 'story_media_url' => url(Storage::url($story->path)), + $metaKey => $text, ], - [ - 'type' => $dmType, - 'status_id' => $status->id, - 'dm_id' => $dm->id, - 'is_hidden' => false, - ] + $status->id, + $url ); - - NotificationService::createNotification($dm->to_id, $dm->from_id, $dmType, $dm->id, DirectMessage::class); } } diff --git a/app/Util/ActivityPub/Inbox/HandlesUpdates.php b/app/Util/ActivityPub/Inbox/HandlesUpdates.php index 0303a00a0..4583069b1 100644 --- a/app/Util/ActivityPub/Inbox/HandlesUpdates.php +++ b/app/Util/ActivityPub/Inbox/HandlesUpdates.php @@ -2,6 +2,7 @@ namespace App\Util\ActivityPub\Inbox; +use App\Federation\Handlers\DirectMessageHandler; use App\Jobs\ProfilePipeline\HandleUpdateActivity; use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline; use App\Models\Status; @@ -28,6 +29,8 @@ trait HandlesUpdates if ($status && $actor && (int) $status->profile_id === (int) $actor->id) { StatusRemoteUpdatePipeline::dispatch($activity); + } elseif (! $status && $actor && $actor->domain !== null) { + app(DirectMessageHandler::class)->handleUpdate($activity, $actor); } } elseif ($activity['type'] === 'Person') { if (UpdatePersonValidator::validate($this->payload)) { diff --git a/config/dm.php b/config/dm.php new file mode 100644 index 000000000..658b5646e --- /dev/null +++ b/config/dm.php @@ -0,0 +1,76 @@ + [ + 'enabled' => (bool) env('DM_GROUPS_ENABLED', true), + 'max_participants' => (int) env('DM_MAX_PARTICIPANTS', 10), + ], + + /* + |-------------------------------------------------------------------------- + | Message limits + |-------------------------------------------------------------------------- + */ + + 'max_message_length' => (int) env('DM_MAX_MESSAGE_LENGTH', 2000), + + 'max_media' => (int) env('DM_MAX_MEDIA', 4), + + /* + |-------------------------------------------------------------------------- + | Message requests + |-------------------------------------------------------------------------- + | + | A recipient who does not accept messages from everyone gets the + | conversation as a request. Until they accept (or reply), a local sender + | can only send `sender_limit` messages, and at most `inbound_limit` + | messages from a remote sender are stored. + | + */ + + 'requests' => [ + 'sender_limit' => (int) env('DM_REQUEST_SENDER_LIMIT', 1), + 'inbound_limit' => (int) env('DM_REQUEST_INBOUND_LIMIT', 5), + ], + + /* + |-------------------------------------------------------------------------- + | Federation + |-------------------------------------------------------------------------- + | + | `max_actor_fetches` caps how many unknown actors a single inbound message + | can make this server fetch while resolving its participants. + | + */ + + 'federation' => [ + 'max_actor_fetches' => (int) env('DM_MAX_ACTOR_FETCHES', 5), + ], + + /* + |-------------------------------------------------------------------------- + | Backfill + |-------------------------------------------------------------------------- + | + | The migration converts legacy direct messages inline only when there are + | fewer rows than this. Larger instances run + | `php artisan dm:backfill-conversations` themselves. + | + */ + + 'backfill' => [ + 'inline_threshold' => (int) env('DM_BACKFILL_INLINE_THRESHOLD', 25000), + ], +]; diff --git a/database/migrations/2026_09_21_000001_create_dm_conversations_table.php b/database/migrations/2026_09_21_000001_create_dm_conversations_table.php new file mode 100644 index 000000000..ef23052fb --- /dev/null +++ b/database/migrations/2026_09_21_000001_create_dm_conversations_table.php @@ -0,0 +1,29 @@ +unsignedBigInteger('id')->primary(); + $table->string('type', 16)->default('dm')->index(); + $table->char('participants_hash', 64)->unique(); + $table->string('name', 100)->nullable(); + $table->string('context_uri', 1024)->nullable(); + $table->string('conversation_uri', 1024)->nullable(); + $table->unsignedBigInteger('created_by_profile_id')->nullable()->index(); + $table->unsignedBigInteger('last_message_id')->nullable(); + $table->timestamp('last_message_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('dm_conversations'); + } +}; diff --git a/database/migrations/2026_09_21_000002_create_dm_conversation_participants_table.php b/database/migrations/2026_09_21_000002_create_dm_conversation_participants_table.php new file mode 100644 index 000000000..8aff757eb --- /dev/null +++ b/database/migrations/2026_09_21_000002_create_dm_conversation_participants_table.php @@ -0,0 +1,32 @@ +bigIncrements('id'); + $table->unsignedBigInteger('conversation_id'); + $table->unsignedBigInteger('profile_id'); + $table->string('state', 16)->default('active'); + $table->unsignedBigInteger('last_read_message_id')->nullable(); + $table->unsignedInteger('unread_count')->default(0); + $table->timestamp('last_activity_at')->nullable(); + $table->timestamp('muted_at')->nullable(); + $table->timestamp('hidden_at')->nullable(); + $table->timestamps(); + + $table->unique(['conversation_id', 'profile_id'], 'dm_cp_conversation_profile_unique'); + $table->index(['profile_id', 'state', 'last_activity_at'], 'dm_cp_inbox_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('dm_conversation_participants'); + } +}; diff --git a/database/migrations/2026_09_21_000003_create_dm_messages_table.php b/database/migrations/2026_09_21_000003_create_dm_messages_table.php new file mode 100644 index 000000000..8317e0a9d --- /dev/null +++ b/database/migrations/2026_09_21_000003_create_dm_messages_table.php @@ -0,0 +1,37 @@ +unsignedBigInteger('id')->primary(); + $table->unsignedBigInteger('conversation_id'); + $table->unsignedBigInteger('profile_id')->index(); + $table->string('type', 32)->default('text'); + $table->text('body')->nullable(); + $table->json('entities')->nullable(); + $table->json('meta')->nullable(); + $table->boolean('is_sensitive')->default(false); + $table->string('ap_object_uri', 1024)->nullable(); + $table->char('ap_object_hash', 64)->nullable()->unique(); + $table->unsignedBigInteger('in_reply_to_id')->nullable(); + $table->unsignedBigInteger('status_id')->nullable()->index(); + $table->unsignedBigInteger('legacy_dm_id')->nullable()->unique(); + $table->timestamp('edited_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['conversation_id', 'id'], 'dm_messages_conversation_id_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('dm_messages'); + } +}; diff --git a/database/migrations/2026_09_21_000004_create_dm_message_media_table.php b/database/migrations/2026_09_21_000004_create_dm_message_media_table.php new file mode 100644 index 000000000..4c0d2a798 --- /dev/null +++ b/database/migrations/2026_09_21_000004_create_dm_message_media_table.php @@ -0,0 +1,23 @@ +bigIncrements('id'); + $table->unsignedBigInteger('message_id')->index(); + $table->unsignedBigInteger('media_id')->unique(); + $table->unsignedTinyInteger('position')->default(0); + }); + } + + public function down(): void + { + Schema::dropIfExists('dm_message_media'); + } +}; diff --git a/database/migrations/2026_09_21_000005_backfill_dm_conversations.php b/database/migrations/2026_09_21_000005_backfill_dm_conversations.php new file mode 100644 index 000000000..28bb52195 --- /dev/null +++ b/database/migrations/2026_09_21_000005_backfill_dm_conversations.php @@ -0,0 +1,42 @@ +count(); + + if ($count === 0) { + return; + } + + if ($count > $threshold) { + echo PHP_EOL." {$count} legacy direct messages found, skipping the inline backfill.".PHP_EOL; + echo ' Run: php artisan dm:backfill-conversations'.PHP_EOL.PHP_EOL; + + return; + } + + Artisan::call('dm:backfill-conversations', ['--force' => true]); + } + + public function down(): void + { + // + } +}; diff --git a/resources/assets/components/partials/direct/Message.vue b/resources/assets/components/partials/direct/Message.vue index 7a49ccee0..3825c9c29 100644 --- a/resources/assets/components/partials/direct/Message.vue +++ b/resources/assets/components/partials/direct/Message.vue @@ -7,7 +7,7 @@ avatar
-

+

-

+

@@ -95,6 +95,9 @@

{{convo.text}}

+

+ {{convo.text}} +

{{ convo.meta.story_actor_username }} reacted your story

@@ -107,7 +110,7 @@
-

+

@@ -129,7 +132,7 @@
-

+

@@ -160,6 +163,9 @@

{{convo.text}}

+

+ {{convo.text}} +

You reacted to {{ convo.meta.story_username }}'s story

@@ -465,6 +471,12 @@ }, methods: { + hasMediaCaption(convo) { + return ['photo', 'photos', 'video', 'videos', 'media'].includes(convo.type) + && !!convo.text + && convo.text.length > 0; + }, + fetchProfile() { axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => { this.profile = res.data; diff --git a/routes/api.php b/routes/api.php index a2f52cab4..27e2bc98b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\AppRegisterController; use App\Http\Controllers\CollectionController; use App\Http\Controllers\ComposeController; use App\Http\Controllers\CustomFilterController; +use App\Http\Controllers\DirectConversationController; use App\Http\Controllers\DirectMessageController; use App\Http\Controllers\DiscoverController; use App\Http\Controllers\FederationController; @@ -160,6 +161,8 @@ Route::prefix('api')->group(function () use ($middleware) { Route::post('avatar/update', [ApiController::class, 'avatarUpdate'])->middleware($middleware); Route::get('blocks', [ApiV1Controller::class, 'accountBlocks'])->middleware($middleware); Route::get('conversations', [ApiV1Controller::class, 'conversations'])->middleware($middleware); + Route::delete('conversations/{id}', [ApiV1Controller::class, 'conversationDelete'])->middleware($middleware); + Route::post('conversations/{id}/read', [ApiV1Controller::class, 'conversationRead'])->middleware($middleware); Route::get('custom_emojis', [ApiV1Controller::class, 'customEmojis']); Route::get('domain_blocks', [DomainBlockController::class, 'index'])->middleware($middleware); Route::post('domain_blocks', [DomainBlockController::class, 'store'])->middleware($middleware); @@ -275,6 +278,21 @@ Route::prefix('api')->group(function () use ($middleware) { Route::post('thread/read', [DirectMessageController::class, 'read'])->middleware($middleware); Route::post('lookup', [DirectMessageController::class, 'composeLookup'])->middleware($middleware); Route::get('compose/mutuals', [DirectMessageController::class, 'composeMutuals'])->middleware($middleware); + + Route::get('unread_count', [DirectConversationController::class, 'unreadCount'])->middleware($middleware); + Route::get('conversations', [DirectConversationController::class, 'index'])->middleware($middleware); + Route::post('conversations', [DirectConversationController::class, 'store'])->middleware($middleware); + Route::get('conversations/{id}', [DirectConversationController::class, 'show'])->middleware($middleware); + Route::get('conversations/{id}/messages', [DirectConversationController::class, 'messages'])->middleware($middleware); + Route::post('conversations/{id}/messages', [DirectConversationController::class, 'send'])->middleware($middleware); + Route::delete('conversations/{id}/messages/{messageId}', [DirectConversationController::class, 'deleteMessage'])->middleware($middleware); + Route::post('conversations/{id}/read', [DirectConversationController::class, 'read'])->middleware($middleware); + Route::post('conversations/{id}/accept', [DirectConversationController::class, 'accept'])->middleware($middleware); + Route::post('conversations/{id}/mute', [DirectConversationController::class, 'mute'])->middleware($middleware); + Route::post('conversations/{id}/unmute', [DirectConversationController::class, 'unmute'])->middleware($middleware); + Route::post('conversations/{id}/hide', [DirectConversationController::class, 'hide'])->middleware($middleware); + Route::post('conversations/{id}/unhide', [DirectConversationController::class, 'unhide'])->middleware($middleware); + Route::post('conversations/{id}/leave', [DirectConversationController::class, 'leave'])->middleware($middleware); }); Route::prefix('archive')->group(function () use ($middleware) { diff --git a/tests/Feature/Api/DirectMessageReadTest.php b/tests/Feature/Api/DirectMessageReadTest.php index 02e1508a4..7ec9bcdbd 100644 --- a/tests/Feature/Api/DirectMessageReadTest.php +++ b/tests/Feature/Api/DirectMessageReadTest.php @@ -1,8 +1,15 @@ withoutMiddleware(ThrottleRequests::class); + config(['snowflake.datacenter_id' => 1, 'snowflake.worker_id' => 1]); +}); + +function readTestUser(): User +{ + $user = User::factory()->create(['created_at' => now()->subYear()]); + $user->refresh(); + + UserSetting::updateOrCreate(['user_id' => $user->id], ['public_dm' => true]); + + return $user; +} + +function readTestSend(User $from, User $to, string $text): DmMessage { - $dm = new DirectMessage; - $dm->to_id = $toId; - $dm->from_id = $fromId; - $dm->status_id = $statusId; - $dm->read_at = null; - $dm->save(); - - return $dm; + $service = app(DirectMessageService::class); + $sender = Profile::findOrFail($from->profile_id); + + return $service->sendMessage( + $service->findOrCreateDm($sender, Profile::findOrFail($to->profile_id)), + $sender, + ['body' => $text] + ); } describe('POST /api/v1.1/direct/thread/read', function () { it('marks matching messages as read and returns their ids', function () { - $recipient = User::factory()->create(); - $recipient->refresh(); - $sender = User::factory()->create(); - $sender->refresh(); + $recipient = readTestUser(); + $sender = readTestUser(); - $dmOne = makeDm($recipient->profile_id, $sender->profile_id, 1000); - $dmTwo = makeDm($recipient->profile_id, $sender->profile_id, 1001); + $one = readTestSend($sender, $recipient, 'one'); + $two = readTestSend($sender, $recipient, 'two'); Passport::actingAs($recipient, ['write']); $response = $this->postJson('/api/v1.1/direct/thread/read', [ 'pid' => $sender->profile_id, - 'sid' => 1000, + 'sid' => $one->id, ]); $response->assertOk(); $returned = collect($response->json())->map(fn ($id) => (int) $id)->all(); - expect($returned)->toContain($dmOne->id)->toContain($dmTwo->id); + expect($returned)->toContain($one->id)->toContain($two->id); + + $state = DmConversationParticipant::where('profile_id', $recipient->profile_id)->first(); - expect(DirectMessage::find($dmOne->id)->read_at)->not->toBeNull(); - expect(DirectMessage::find($dmTwo->id)->read_at)->not->toBeNull(); + expect($state->last_read_message_id)->toBe($two->id) + ->and($state->unread_count)->toBe(0); }); - it('does not mark messages below the given status id', function () { - $recipient = User::factory()->create(); - $recipient->refresh(); - $sender = User::factory()->create(); - $sender->refresh(); + it('does not report messages below the given id', function () { + $recipient = readTestUser(); + $sender = readTestUser(); - $older = makeDm($recipient->profile_id, $sender->profile_id, 500); - $newer = makeDm($recipient->profile_id, $sender->profile_id, 900); + $older = readTestSend($sender, $recipient, 'older'); + $newer = readTestSend($sender, $recipient, 'newer'); Passport::actingAs($recipient, ['write']); - $this->postJson('/api/v1.1/direct/thread/read', [ + $returned = collect($this->postJson('/api/v1.1/direct/thread/read', [ 'pid' => $sender->profile_id, - 'sid' => 900, - ])->assertOk(); + 'sid' => $newer->id, + ])->assertOk()->json())->map(fn ($id) => (int) $id)->all(); - expect(DirectMessage::find($older->id)->read_at)->toBeNull(); - expect(DirectMessage::find($newer->id)->read_at)->not->toBeNull(); + expect($returned)->toBe([$newer->id]); }); it('does not mark another senders messages as read', function () { - $recipient = User::factory()->create(); - $recipient->refresh(); - $sender = User::factory()->create(); - $sender->refresh(); - $other = User::factory()->create(); - $other->refresh(); + $recipient = readTestUser(); + $sender = readTestUser(); + $other = readTestUser(); - $fromSender = makeDm($recipient->profile_id, $sender->profile_id, 700); - $fromOther = makeDm($recipient->profile_id, $other->profile_id, 700); + $fromSender = readTestSend($sender, $recipient, 'hi'); + readTestSend($other, $recipient, 'hey'); Passport::actingAs($recipient, ['write']); $this->postJson('/api/v1.1/direct/thread/read', [ 'pid' => $sender->profile_id, - 'sid' => 700, + 'sid' => $fromSender->id, ])->assertOk(); - expect(DirectMessage::find($fromSender->id)->read_at)->not->toBeNull(); - expect(DirectMessage::find($fromOther->id)->read_at)->toBeNull(); + $states = DmConversationParticipant::where('profile_id', $recipient->profile_id)->get(); + + expect($states->firstWhere('last_read_message_id', $fromSender->id))->not->toBeNull() + ->and($states->sum('unread_count'))->toBe(1); }); }); diff --git a/tests/Feature/DirectMessage/DirectMessageApiTest.php b/tests/Feature/DirectMessage/DirectMessageApiTest.php new file mode 100644 index 000000000..6fb2bd6f2 --- /dev/null +++ b/tests/Feature/DirectMessage/DirectMessageApiTest.php @@ -0,0 +1,493 @@ +withoutMiddleware(ThrottleRequests::class); + + config([ + 'instance.enable_cc' => false, + 'federation.activitypub.enabled' => true, + 'snowflake.datacenter_id' => 1, + 'snowflake.worker_id' => 1, + ]); +}); + +function dmUpload(User $user, string $mime = 'image/jpeg'): Media +{ + return Media::create([ + 'status_id' => null, + 'profile_id' => $user->profile_id, + 'user_id' => $user->id, + 'media_path' => 'public/m/'.Str::random(12).'.jpg', + 'mime' => $mime, + 'size' => 1000, + ]); +} + +describe('starting a conversation', function () { + it('creates a one to one conversation and returns the same one next time', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + + $first = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]]); + $first->assertCreated() + ->assertJsonPath('type', 'dm') + ->assertJsonPath('participants.0.id', (string) $bob->profile_id) + ->assertJsonPath('last_message', null); + + $second = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]]); + $second->assertOk()->assertJsonPath('id', $first->json('id')); + + expect(DmConversation::count())->toBe(1); + }); + + it('creates a group for several recipients', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $carol = dmRemoteProfile('carol'); + + Passport::actingAs($alice, ['read', 'write']); + + $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id, $carol->id]]) + ->assertCreated() + ->assertJsonPath('type', 'group') + ->assertJsonPath('participant_count', 3) + ->assertJsonCount(2, 'participants'); + }); + + it('refuses a recipient who blocks the sender', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + UserFilter::create([ + 'user_id' => $bob->profile_id, + 'filterable_id' => $alice->profile_id, + 'filterable_type' => 'App\Models\Profile', + 'filter_type' => 'block', + ]); + + Passport::actingAs($alice, ['read', 'write']); + + $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->assertForbidden(); + }); + + it('makes new accounts wait', function () { + config(['instance.allow_new_account_dms' => false]); + + $alice = dmLocalUser(attributes: ['created_at' => now()]); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + + $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->assertStatus(400); + }); + + it('requires the write scope', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read']); + + $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->assertForbidden(); + }); +}); + +describe('sending', function () { + it('sends text and media as one message', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $media = dmUpload($alice); + + Passport::actingAs($alice, ['read', 'write']); + + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", [ + 'message' => 'look at this', + 'media_ids' => [$media->id], + ]) + ->assertCreated() + ->assertJsonPath('text', 'look at this') + ->assertJsonPath('type', 'photo') + ->assertJsonPath('media.0.id', (string) $media->id) + ->assertJsonPath('is_author', true); + + expect(DmMessage::count())->toBe(1); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson("/api/v1.1/direct/conversations/{$id}/messages") + ->assertOk() + ->assertJsonPath('data.0.text', 'look at this') + ->assertJsonPath('data.0.media.0.id', (string) $media->id) + ->assertJsonPath('data.0.is_author', false); + }); + + it('does not accept media that belongs to someone else or is already used', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $theirs = dmUpload($bob); + $mine = dmUpload($alice); + + Passport::actingAs($alice, ['read', 'write']); + + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['media_ids' => [$theirs->id]])->assertStatus(422); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['media_ids' => [$mine->id]])->assertCreated(); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['media_ids' => [$mine->id]])->assertStatus(422); + }); + + it('updates the recipients inbox, unread count and notifications', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'hi'])->assertCreated(); + + expect(Notification::where('profile_id', $bob->profile_id)->where('action', 'dm')->count())->toBe(1); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson('/api/v1.1/direct/conversations') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.unread_count', 1) + ->assertJsonPath('data.0.last_message.text', 'hi') + ->assertJsonPath('data.0.participants.0.id', (string) $alice->profile_id); + + $this->getJson('/api/v1.1/direct/unread_count')->assertJsonPath('primary', 1); + + $this->postJson("/api/v1.1/direct/conversations/{$id}/read")->assertOk()->assertJsonPath('unread_count', 0); + }); + + it('holds a first message as a request and limits the sender until it is accepted', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(publicDm: false); + + Passport::actingAs($alice, ['read', 'write']); + + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'one'])->assertCreated(); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'two'])->assertForbidden(); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson('/api/v1.1/direct/conversations')->assertJsonCount(0, 'data'); + $this->getJson('/api/v1.1/direct/conversations?filter=requests')->assertJsonCount(1, 'data'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/accept")->assertJsonPath('state', 'active'); + $this->getJson('/api/v1.1/direct/conversations')->assertJsonCount(1, 'data'); + + Passport::actingAs($alice, ['read', 'write']); + + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'two'])->assertCreated(); + }); + + it('accepts a request when the recipient replies', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(publicDm: false); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'one'])->assertCreated(); + + Passport::actingAs($bob, ['read', 'write']); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'hey'])->assertCreated(); + + expect(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('state'))->toBe('active'); + }); + + it('hides a conversation from people who are not in it', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $eve = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'secret'])->assertCreated(); + + Passport::actingAs($eve, ['read', 'write']); + + $this->getJson("/api/v1.1/direct/conversations/{$id}")->assertNotFound(); + $this->getJson("/api/v1.1/direct/conversations/{$id}/messages")->assertNotFound(); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'hello?'])->assertNotFound(); + }); + + it('pages back with max_id and polls forward with min_id', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + + $ids = []; + foreach (['a', 'b', 'c'] as $text) { + $ids[] = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => $text])->json('id'); + } + + $this->getJson("/api/v1.1/direct/conversations/{$id}/messages?limit=2") + ->assertJsonPath('data.0.text', 'c') + ->assertJsonPath('data.1.text', 'b') + ->assertJsonPath('meta.has_more', true) + ->assertJsonPath('meta.oldest_id', $ids[1]); + + $this->getJson("/api/v1.1/direct/conversations/{$id}/messages?max_id={$ids[1]}") + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.text', 'a'); + + $this->getJson("/api/v1.1/direct/conversations/{$id}/messages?min_id={$ids[0]}") + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.text', 'c'); + }); +}); + +describe('groups', function () { + it('delivers to every member and keeps a blocked sender out of view', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $dave = dmLocalUser(); + + UserFilter::create([ + 'user_id' => $dave->profile_id, + 'filterable_id' => $alice->profile_id, + 'filterable_type' => 'App\Models\Profile', + 'filter_type' => 'block', + ]); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id, $dave->profile_id]])->json('id'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'from alice'])->assertCreated(); + + Passport::actingAs($bob, ['read', 'write']); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'from bob'])->assertCreated(); + + Passport::actingAs($dave, ['read', 'write']); + + $this->getJson("/api/v1.1/direct/conversations/{$id}/messages") + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.text', 'from bob'); + + $this->getJson('/api/v1.1/direct/conversations')->assertJsonPath('data.0.unread_count', 1); + }); + + it('lets a member leave without changing the conversation for the others', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $dave = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id, $dave->profile_id]])->json('id'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'hi all'])->assertCreated(); + + Passport::actingAs($dave, ['read', 'write']); + $this->postJson("/api/v1.1/direct/conversations/{$id}/leave")->assertJsonPath('state', 'left'); + $this->getJson("/api/v1.1/direct/conversations/{$id}")->assertNotFound(); + + Passport::actingAs($alice, ['read', 'write']); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'still here'])->assertCreated(); + + expect(DmConversationParticipant::where('profile_id', $dave->profile_id)->value('unread_count'))->toBe(0) + ->and(DmConversationParticipant::where('conversation_id', $id)->count())->toBe(3); + }); + + it('cannot leave a one to one conversation', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + + $this->postJson("/api/v1.1/direct/conversations/{$id}/leave")->assertStatus(422); + }); +}); + +describe('deleting and reporting', function () { + it('lets the author delete a message and repairs the conversation', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + $first = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'one'])->json('id'); + $second = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'two'])->json('id'); + + $this->deleteJson("/api/v1.1/direct/conversations/{$id}/messages/{$second}")->assertOk(); + + expect((string) DmConversation::find($id)->last_message_id)->toBe($first) + ->and(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('unread_count'))->toBe(1) + ->and(Notification::where('item_type', DmMessage::class)->where('item_id', $second)->count())->toBe(0); + + Passport::actingAs($bob, ['read', 'write']); + $this->deleteJson("/api/v1.1/direct/conversations/{$id}/messages/{$first}")->assertNotFound(); + }); + + it('lets a participant report a message and nobody else', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $eve = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + $message = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'spam'])->json('id'); + + Passport::actingAs($eve, ['read', 'write']); + $this->postJson('/api/v1.1/report', ['report_type' => 'spam', 'object_type' => 'direct_message', 'object_id' => $message])->assertStatus(400); + + Passport::actingAs($bob, ['read', 'write']); + $this->postJson('/api/v1.1/report', ['report_type' => 'spam', 'object_type' => 'direct_message', 'object_id' => $message])->assertOk(); + + $report = Report::first(); + + expect($report->object_type)->toBe(DmMessage::class) + ->and((string) $report->object_id)->toBe($message) + ->and($report->reported_profile_id)->toBe($alice->profile_id); + }); +}); + +describe('federation', function () { + it('delivers once per server with the fields mastodon needs to thread it as a direct message', function () { + $alice = dmLocalUser(); + $bob = dmRemoteProfile('bob'); + $carol = dmRemoteProfile('carol'); + $dave = dmRemoteProfile('dave', 'other.example'); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->id, $carol->id, $dave->id]])->json('id'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => "hello all\nsecond line"])->assertCreated(); + + Queue::assertPushed(DeliverDirectMessageActivity::class, 2); + + $inboxes = []; + $activity = null; + + Queue::assertPushed(DeliverDirectMessageActivity::class, function ($job) use (&$inboxes, &$activity) { + $inboxes[] = $job->inbox(); + $activity = $job->activity(); + + return true; + }); + + sort($inboxes); + expect($inboxes)->toBe(['https://other.example/inbox', 'https://remote.example/inbox']); + + $note = $activity['object']; + $recipients = [$bob->remote_url, $carol->remote_url, $dave->remote_url]; + + expect($activity['type'])->toBe('Create') + ->and($note['type'])->toBe('Note') + ->and($note['to'])->toEqualCanonicalizing($recipients) + ->and($note['cc'])->toBe([]) + ->and(collect($note['tag'])->where('type', 'Mention')->pluck('href')->all())->toEqualCanonicalizing($recipients) + ->and($note['context'])->toBe(DmConversation::localContextUri($id)) + ->and($note['conversation'])->toBe(DmConversation::localContextUri($id)) + ->and($note['inReplyTo'])->toBeNull() + ->and($note['content'])->toBe("

hello <b>all</b>
\nsecond line

"); + + expect(json_encode($activity))->not->toContain('#Public'); + }); + + it('replies into the remote thread it was started from', function () { + $alice = dmLocalUser(); + $aliceProfile = dmProfile($alice); + $bob = dmRemoteProfile('bob'); + dmSeedHosts(); + + dmDeliver($bob, dmNote($bob, '1', [$aliceProfile])); + + $conversation = DmConversation::first(); + + Passport::actingAs($alice, ['read', 'write']); + $this->postJson("/api/v1.1/direct/conversations/{$conversation->id}/messages", ['message' => 'hi bob'])->assertCreated(); + $this->postJson("/api/v1.1/direct/conversations/{$conversation->id}/messages", ['message' => 'and another'])->assertCreated(); + + $notes = []; + Queue::assertPushed(DeliverDirectMessageActivity::class, function ($job) use (&$notes) { + $notes[] = $job->activity()['object']; + + return true; + }); + + expect($notes[0]['inReplyTo'])->toBe($bob->remote_url.'/statuses/1') + ->and($notes[0]['context'])->toBe('https://remote.example/contexts/1') + ->and($notes[0]['conversation'])->toBe('tag:remote.example,2026-09-21:objectId=1:objectType=Conversation') + ->and($notes[1]['inReplyTo'])->toBe($notes[0]['id']); + }); + + it('sends media and its description along with the text', function () { + $alice = dmLocalUser(); + $bob = dmRemoteProfile('bob'); + $media = dmUpload($alice); + $media->update(['caption' => 'a cat', 'width' => 100, 'height' => 50]); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), $bob); + $message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'look', 'media' => collect([$media])]); + + $note = app(DirectMessageActivityBuilder::class)->buildNote($message, $conversation, dmProfile($alice), collect([$bob])); + + expect($note['attachment'])->toHaveCount(1) + ->and($note['attachment'][0]['mediaType'])->toBe('image/jpeg') + ->and($note['attachment'][0]['name'])->toBe('a cat') + ->and($note['content'])->toBe('

look

'); + }); + + it('tells the other servers when a message is deleted, addressed to them and not the public', function () { + $alice = dmLocalUser(); + $bob = dmRemoteProfile('bob'); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->id]])->json('id'); + $message = $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'oops'])->json('id'); + + $this->deleteJson("/api/v1.1/direct/conversations/{$id}/messages/{$message}")->assertOk(); + + Queue::assertPushed(DeliverDirectMessageActivity::class, function ($job) use ($bob, $message) { + $activity = $job->activity(); + + return $activity['type'] === 'Delete' + && $activity['to'] === [$bob->remote_url] + && $activity['object']['id'] === DmMessage::localObjectUri($message); + }); + }); + + it('does not federate a conversation between local people', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/conversations', ['recipient_ids' => [$bob->profile_id]])->json('id'); + $this->postJson("/api/v1.1/direct/conversations/{$id}/messages", ['message' => 'local'])->assertCreated(); + + Queue::assertNotPushed(DeliverDirectMessageActivity::class); + }); +}); diff --git a/tests/Feature/DirectMessage/DirectMessageBackfillTest.php b/tests/Feature/DirectMessage/DirectMessageBackfillTest.php new file mode 100644 index 000000000..005d7fe46 --- /dev/null +++ b/tests/Feature/DirectMessage/DirectMessageBackfillTest.php @@ -0,0 +1,235 @@ + 1, 'snowflake.worker_id' => 1]); +}); + +function dmLegacy(Profile $from, Profile $to, string $text, array $dm = [], array $status = []): DirectMessage +{ + $post = Status::factory()->create(array_merge([ + 'profile_id' => $from->id, + 'caption' => $text, + 'scope' => 'direct', + 'visibility' => 'direct', + 'in_reply_to_profile_id' => $to->id, + ], $status)); + + $row = new DirectMessage; + $row->from_id = $from->id; + $row->to_id = $to->id; + $row->status_id = $post->id; + $row->type = $dm['type'] ?? 'text'; + $row->is_hidden = $dm['is_hidden'] ?? false; + $row->read_at = $dm['read_at'] ?? null; + $row->meta = $dm['meta'] ?? null; + $row->save(); + + return $row; +} + +describe('dm:backfill-conversations', function () { + it('turns a legacy thread into one conversation and keeps the status ids', function () { + $alice = dmProfile(dmLocalUser()); + $bob = dmProfile(dmLocalUser()); + + $one = dmLegacy($alice, $bob, 'first', ['read_at' => now()]); + $two = dmLegacy($bob, $alice, 'second', ['read_at' => now()]); + $three = dmLegacy($alice, $bob, 'third'); + + $this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful(); + + $conversation = DmConversation::first(); + + expect(DmConversation::count())->toBe(1) + ->and($conversation->participants_hash)->toBe(DmConversation::dmHash($alice->id, $bob->id)) + ->and($conversation->last_message_id)->toBe($three->status_id) + ->and(DmMessage::orderBy('id')->pluck('id')->all())->toBe([$one->status_id, $two->status_id, $three->status_id]) + ->and(DmMessage::orderBy('id')->pluck('body')->all())->toBe(['first', 'second', 'third']) + ->and(DmMessage::find($one->status_id)->legacy_dm_id)->toBe($one->id); + + $bobState = DmConversationParticipant::where('profile_id', $bob->id)->first(); + $aliceState = DmConversationParticipant::where('profile_id', $alice->id)->first(); + + expect($bobState->unread_count)->toBe(1) + ->and($bobState->last_read_message_id)->toBe($one->status_id) + ->and($bobState->last_activity_at)->not->toBeNull() + ->and($aliceState->unread_count)->toBe(0) + ->and($aliceState->last_read_message_id)->toBe($three->status_id); + }); + + it('can be run again without duplicating anything', function () { + $alice = dmProfile(dmLocalUser()); + $bob = dmProfile(dmLocalUser()); + + dmLegacy($alice, $bob, 'first'); + + $this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful(); + dmLegacy($bob, $alice, 'second'); + $this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful(); + $this->artisan('dm:backfill-conversations', ['--force' => true, '--full' => true])->assertSuccessful(); + + expect(DmMessage::count())->toBe(2) + ->and(DmConversation::count())->toBe(1) + ->and(DmConversationParticipant::count())->toBe(2); + }); + + it('carries over media, filtered threads, story metadata and mutes', function () { + $alice = dmProfile(dmLocalUser()); + $bob = dmProfile(dmLocalUser()); + $carol = dmRemoteProfile('carol'); + + $photo = dmLegacy($alice, $bob, 'with a photo', ['type' => 'photo']); + $media = Media::create([ + 'status_id' => $photo->status_id, + 'profile_id' => $alice->id, + 'media_path' => 'public/m/legacy.jpg', + 'mime' => 'image/jpeg', + 'size' => 1000, + ]); + + dmLegacy($carol, $bob, 'Tom & Jerry', ['is_hidden' => true], ['uri' => $carol->remote_url.'/statuses/9']); + + dmLegacy($alice, $bob, '🔥', [ + 'type' => 'story:react', + 'meta' => json_encode(['story_id' => 5, 'reaction' => '🔥']), + ], ['type' => 'story:reaction']); + + UserFilter::create([ + 'user_id' => $bob->id, + 'filterable_id' => $alice->id, + 'filterable_type' => 'App\Models\Profile', + 'filter_type' => 'dm.mute', + ]); + + $this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful(); + + $withPhoto = DmMessage::with('media')->find($photo->status_id); + $fromCarol = DmMessage::where('profile_id', $carol->id)->first(); + $reaction = DmMessage::where('type', 'story:react')->first(); + + expect($withPhoto->media->pluck('id')->all())->toBe([$media->id]) + ->and($withPhoto->body)->toBe('with a photo') + ->and($fromCarol->body)->toBe('Tom & Jerry') + ->and($fromCarol->ap_object_uri)->toBe($carol->remote_url.'/statuses/9') + ->and(DmMessage::whereObjectUri($carol->remote_url.'/statuses/9')->exists())->toBeTrue() + ->and($reaction->meta)->toBe(['story_id' => 5, 'reaction' => '🔥']); + + $carolThread = DmConversation::where('participants_hash', DmConversation::dmHash($carol->id, $bob->id))->first(); + $aliceThread = DmConversation::where('participants_hash', DmConversation::dmHash($alice->id, $bob->id))->first(); + + expect(DmConversationParticipant::where('conversation_id', $carolThread->id)->where('profile_id', $bob->id)->value('state'))->toBe('request') + ->and(DmConversationParticipant::where('conversation_id', $aliceThread->id)->where('profile_id', $bob->id)->value('muted_at'))->not->toBeNull(); + }); +}); + +describe('cleanup', function () { + it('removes the message when the status behind it is deleted', function () { + $alice = dmProfile(dmLocalUser()); + $bob = dmProfile(dmLocalUser()); + + $legacy = dmLegacy($alice, $bob, 'first'); + $this->artisan('dm:backfill-conversations', ['--force' => true])->assertSuccessful(); + + app(DirectMessageService::class)->deleteByStatusId($legacy->status_id); + + expect(DmMessage::count())->toBe(0) + ->and(DmConversation::first()->last_message_id)->toBeNull(); + }); + + it('clears a deleted profile out of its conversations', function () { + $alice = dmProfile(dmLocalUser()); + $bob = dmProfile(dmLocalUser()); + $dave = dmProfile(dmLocalUser()); + + $service = app(DirectMessageService::class); + + $direct = $service->findOrCreateDm($alice, $bob); + $service->sendMessage($direct, $alice, ['body' => 'one to one']); + $service->sendMessage($direct, $bob, ['body' => 'reply']); + + $group = $service->findOrCreateConversation($alice, collect([$bob, $dave])); + $service->sendMessage($group, $alice, ['body' => 'from alice']); + $service->sendMessage($group, $bob, ['body' => 'from bob']); + + $service->purgeProfile($alice->id); + + expect(DmConversation::find($direct->id))->toBeNull() + ->and(DmMessage::where('conversation_id', $direct->id)->count())->toBe(0) + ->and(DmMessage::where('conversation_id', $group->id)->pluck('body')->all())->toBe(['from bob']) + ->and(DmConversationParticipant::where('conversation_id', $group->id)->where('profile_id', $alice->id)->value('state'))->toBe('left'); + }); + + it('keeps direct message media away from the orphan media collector', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + $attached = Media::create(['profile_id' => $alice->profile_id, 'user_id' => $alice->id, 'media_path' => 'public/m/a.jpg', 'mime' => 'image/jpeg', 'size' => 1]); + $orphan = Media::create(['profile_id' => $alice->profile_id, 'user_id' => $alice->id, 'media_path' => 'public/m/b.jpg', 'mime' => 'image/jpeg', 'size' => 1]); + + DB::table('media')->whereIn('id', [$attached->id, $orphan->id])->update(['created_at' => now()->subDay()]); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob)); + $service->sendMessage($conversation, dmProfile($alice), ['media' => collect([$attached])]); + + $collectable = Media::whereNull('status_id') + ->notInDirectMessage() + ->where('created_at', '<', now()->subHours(2)) + ->pluck('id') + ->all(); + + expect($collectable)->toBe([$orphan->id]); + }); +}); + +describe('story replies', function () { + it('lands in the conversation with the story author', function () { + $alice = dmProfile(dmLocalUser()); + $bob = dmProfile(dmLocalUser(publicDm: false)); + + $message = app(DirectMessageService::class)->storeStoryMessage( + $alice, + $bob, + 'story:comment', + 'nice one', + ['story_id' => 1, 'caption' => 'nice one'], + 12345 + ); + + $state = DmConversationParticipant::where('profile_id', $bob->id)->first(); + + expect($message->type)->toBe('story:comment') + ->and($message->status_id)->toBe(12345) + ->and($message->meta['caption'])->toBe('nice one') + ->and($state->state)->toBe('active') + ->and($state->unread_count)->toBe(1) + ->and(Notification::where('profile_id', $bob->id)->where('action', 'story:comment')->count())->toBe(1); + + // The story pipeline federates these, not the direct message one + Queue::assertNotPushed(DeliverDirectMessageActivity::class); + }); +}); diff --git a/tests/Feature/DirectMessage/DirectMessageInboundTest.php b/tests/Feature/DirectMessage/DirectMessageInboundTest.php new file mode 100644 index 000000000..e5b000594 --- /dev/null +++ b/tests/Feature/DirectMessage/DirectMessageInboundTest.php @@ -0,0 +1,610 @@ + false, + 'federation.activitypub.enabled' => true, + 'snowflake.datacenter_id' => 1, + 'snowflake.worker_id' => 1, + ]); +}); + +describe('one to one', function () { + it('stores a direct note as a message in a conversation with its author', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + $message = DmMessage::first(); + + expect($message)->not->toBeNull() + ->and($message->body)->toBe('hello there') + ->and($message->profile_id)->toBe($alice->id) + ->and($message->ap_object_uri)->toBe($alice->remote_url.'/statuses/1'); + + $conversation = DmConversation::find($message->conversation_id); + + expect($conversation->type)->toBe('dm') + ->and($conversation->participants_hash)->toBe(DmConversation::dmHash($alice->id, $bob->id)) + ->and($conversation->last_message_id)->toBe($message->id); + + expect(Status::count())->toBe(0); + }); + + it('keeps the thread identifiers the remote server sent', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + $conversation = DmConversation::first(); + + expect($conversation->context_uri)->toBe('https://remote.example/contexts/1') + ->and($conversation->conversation_uri)->toBe('tag:remote.example,2026-09-21:objectId=1:objectType=Conversation'); + }); + + it('counts the message as unread and notifies the recipient', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + $participant = DmConversationParticipant::where('profile_id', $bob->id)->first(); + + expect($participant->state)->toBe('active') + ->and($participant->unread_count)->toBe(1) + ->and($participant->last_activity_at)->not->toBeNull(); + + $notification = Notification::where('profile_id', $bob->id)->first(); + + expect($notification)->not->toBeNull() + ->and($notification->action)->toBe('dm') + ->and($notification->item_type)->toBe(DmMessage::class); + }); + + it('resolves a recipient addressed by an id based actor url', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob], ['to' => [url('/users/'.$bob->id)]])); + + expect(DmMessage::count())->toBe(1); + }); + + it('stores a redelivered message once', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + expect(DmMessage::count())->toBe(1) + ->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1); + }); + + it('does not store a message that already exists as a legacy direct status', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + Status::factory()->create([ + 'profile_id' => $alice->id, + 'caption' => 'old', + 'scope' => 'direct', + 'visibility' => 'direct', + 'uri' => $alice->remote_url.'/statuses/1', + ]); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + expect(DmMessage::count())->toBe(0); + }); + + it('turns the conversation into a request when the recipient only takes messages from people they follow', function () { + $bob = dmProfile(dmLocalUser(publicDm: false)); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + expect(DmConversationParticipant::where('profile_id', $bob->id)->value('state'))->toBe('request') + ->and(Notification::where('profile_id', $bob->id)->count())->toBe(0); + }); + + it('skips the request when the recipient follows the sender', function () { + $bob = dmProfile(dmLocalUser(publicDm: false)); + $alice = dmRemoteProfile(); + dmFollow($bob, $alice); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + expect(DmConversationParticipant::where('profile_id', $bob->id)->value('state'))->toBe('active'); + }); + + it('stops storing messages once a pending request hits the inbound limit', function () { + config(['dm.requests.inbound_limit' => 2]); + + $bob = dmProfile(dmLocalUser(publicDm: false)); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + foreach (['1', '2', '3'] as $path) { + dmDeliver($alice, dmNote($alice, $path, [$bob])); + } + + expect(DmMessage::count())->toBe(2); + }); + + it('drops a message from someone the recipient blocks', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + UserFilter::create([ + 'user_id' => $bob->id, + 'filterable_id' => $alice->id, + 'filterable_type' => 'App\Models\Profile', + 'filter_type' => 'block', + ]); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + expect(DmMessage::count())->toBe(0) + ->and(DmConversation::count())->toBe(0); + }); + + it('drops a message from a domain the recipient blocks', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + UserDomainBlock::create(['profile_id' => $bob->id, 'domain' => 'remote.example']); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + + expect(DmMessage::count())->toBe(0); + }); + + it('does not store a message attributed to someone other than the sender', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + $mallory = dmRemoteProfile('mallory', 'other.example'); + dmSeedHosts(); + + dmDeliver($mallory, dmNote($alice, '1', [$bob])); + + expect(DmMessage::count())->toBe(0) + ->and(Status::count())->toBe(0); + }); +}); + +describe('text and media', function () { + it('keeps the text of a message that also has media', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob], [ + 'content' => '

look at this

', + 'attachment' => [[ + 'type' => 'Document', + 'mediaType' => 'image/jpeg', + 'url' => 'https://remote.example/media/1.jpg', + 'name' => 'a cat', + 'blurhash' => 'UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH', + 'width' => 1200, + 'height' => 800, + ]], + ])); + + $message = DmMessage::with('media')->first(); + + expect($message->body)->toBe('look at this') + ->and($message->type)->toBe('photo') + ->and($message->media)->toHaveCount(1) + ->and($message->media[0]->remote_url)->toBe('https://remote.example/media/1.jpg') + ->and($message->media[0]->status_id)->toBeNull() + ->and($message->media[0]->caption)->toBe('a cat'); + }); + + it('stores a message that is only media', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob], [ + 'content' => '', + 'attachment' => [ + ['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg'], + ['type' => 'Document', 'mediaType' => 'image/png', 'url' => 'https://remote.example/media/2.png'], + ], + ])); + + $message = DmMessage::with('media')->first(); + + expect($message->body)->toBeNull() + ->and($message->type)->toBe('photos') + ->and($message->media)->toHaveCount(2); + }); + + it('ignores attachments of a type this server does not accept', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob], [ + 'attachment' => [ + ['type' => 'Document', 'mediaType' => 'application/x-msdownload', 'url' => 'https://remote.example/media/1.exe'], + ], + ])); + + $message = DmMessage::with('media')->first(); + + expect($message->type)->toBe('text') + ->and($message->media)->toHaveCount(0); + }); + + it('keeps direct message media out of the unattached media queries', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob], [ + 'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']], + ])); + + expect(Media::whereNull('status_id')->count())->toBe(1) + ->and(Media::whereNull('status_id')->notInDirectMessage()->count())->toBe(0); + }); +}); + +describe('plain text', function () { + it('drops the leading mentions and keeps paragraphs and line breaks', function () { + $html = '

@bob ' + .'@carol first line
second line

' + .'

new paragraph & more

'; + + expect(DirectMessageHandler::plainText($html))->toBe("first line\nsecond line\n\nnew paragraph & more"); + }); + + it('leaves a mention in the middle of the text alone', function () { + expect(DirectMessageHandler::plainText('

@bob have you met @carol@other.example yet

')) + ->toBe('have you met @carol@other.example yet'); + }); + + it('does not keep markup or script content', function () { + expect(DirectMessageHandler::plainText('

hi there

'))->toBe('hi there'); + }); + + it('returns null when nothing is left', function () { + expect(DirectMessageHandler::plainText('

@bob

'))->toBeNull(); + }); +}); + +describe('groups', function () { + it('stores a note addressed to several people as one group conversation', function () { + $bob = dmProfile(dmLocalUser()); + $dave = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + $carol = dmRemoteProfile('carol', 'other.example'); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob, $dave, $carol])); + + $conversation = DmConversation::first(); + + expect(DmConversation::count())->toBe(1) + ->and($conversation->type)->toBe('group') + ->and($conversation->participants_hash)->toBe(DmConversation::participantsHash([$alice->id, $bob->id, $dave->id, $carol->id])) + ->and(DmConversationParticipant::where('conversation_id', $conversation->id)->count())->toBe(4) + ->and(DmMessage::count())->toBe(1); + }); + + it('reads recipients from cc as well as to', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + $carol = dmRemoteProfile('carol', 'other.example'); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob], ['cc' => [$carol->remote_url]])); + + expect(DmConversation::first()->type)->toBe('group'); + }); + + it('never stores a group message as a followers-only post', function () { + $bob = dmProfile(dmLocalUser()); + $dave = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmFollow($bob, $alice); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob, $dave], [ + 'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']], + ])); + + expect(Status::count())->toBe(0) + ->and(DmMessage::count())->toBe(1); + }); + + it('never stores a group message that replies to a local post as a comment', function () { + $bobUser = dmLocalUser(); + $bob = dmProfile($bobUser); + $dave = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + $post = Status::factory()->photo()->create(['profile_id' => $bob->id]); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob, $dave], ['inReplyTo' => $post->url()])); + + expect(Status::where('in_reply_to_id', $post->id)->count())->toBe(0) + ->and(DmMessage::count())->toBe(1); + }); + + it('drops a direct note that names nobody on this server instead of storing it as a post', function () { + dmLocalUser(); + $alice = dmRemoteProfile(); + $carol = dmRemoteProfile('carol', 'other.example'); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$carol], [ + 'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']], + ])); + + expect(Status::count())->toBe(0) + ->and(DmMessage::count())->toBe(0); + }); + + it('drops a note addressed to more people than a group allows', function () { + config(['dm.groups.max_participants' => 3]); + + $bob = dmProfile(dmLocalUser()); + $dave = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + $carol = dmRemoteProfile('carol', 'other.example'); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob, $dave, $carol])); + + expect(DmMessage::count())->toBe(0) + ->and(Status::count())->toBe(0); + }); + + it('hides the message from a member who blocks the sender without telling anyone', function () { + $bob = dmProfile(dmLocalUser()); + $dave = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + UserFilter::create([ + 'user_id' => $dave->id, + 'filterable_id' => $alice->id, + 'filterable_type' => 'App\Models\Profile', + 'filter_type' => 'block', + ]); + + dmDeliver($alice, dmNote($alice, '1', [$bob, $dave])); + + expect(DmMessage::count())->toBe(1) + ->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1) + ->and(DmConversationParticipant::where('profile_id', $dave->id)->value('unread_count'))->toBe(0) + ->and(DmConversationParticipant::where('profile_id', $dave->id)->value('last_activity_at'))->toBeNull() + ->and(Notification::where('profile_id', $dave->id)->count())->toBe(0); + }); + + it('drops a message when the only person it could reach here has left the group', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + $carol = dmRemoteProfile('carol', 'other.example'); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob, $carol])); + + DmConversationParticipant::where('profile_id', $bob->id)->update(['state' => 'left']); + + dmDeliver($alice, dmNote($alice, '2', [$bob, $carol])); + + expect(DmMessage::count())->toBe(1); + }); + + it('starts a separate conversation when the set of people changes', function () { + $bob = dmProfile(dmLocalUser()); + $dave = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + dmDeliver($alice, dmNote($alice, '2', [$bob, $dave])); + + expect(DmConversation::count())->toBe(2); + }); +}); + +describe('audience', function () { + it('does not treat public, unlisted or followers-only notes as direct', function () { + $alice = dmRemoteProfile(); + $bobUrl = 'https://pixelfed.test/users/bob'; + + expect(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'cc' => [$bobUrl]], $alice))->toBeFalse() + ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$bobUrl], 'cc' => ['as:Public']], $alice))->toBeFalse() + ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$alice->remote_url.'/followers'], 'cc' => [$bobUrl]], $alice))->toBeFalse() + ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => ['https://friendica.example/followers/alice'], 'cc' => []], $alice))->toBeFalse() + ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => $bobUrl], $alice))->toBeTrue() + ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => [$bobUrl, 'https://other.example/users/carol']], $alice))->toBeTrue() + ->and(DirectMessageValidator::isDirect(['type' => 'Note', 'to' => []], $alice))->toBeFalse(); + }); + + it('still counts a poll vote as a vote', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, [ + 'id' => $alice->remote_url.'/votes/1', + 'type' => 'Note', + 'attributedTo' => $alice->remote_url, + 'name' => 'Option A', + 'inReplyTo' => 'https://remote.example/polls/1', + 'to' => [$bob->permalink()], + ]); + + expect(DmMessage::count())->toBe(0); + }); +}); + +describe('threading', function () { + it('links a reply to the message it answers', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + dmDeliver($alice, dmNote($alice, '2', [$bob], ['inReplyTo' => $alice->remote_url.'/statuses/1'])); + + $first = DmMessage::whereObjectUri($alice->remote_url.'/statuses/1')->first(); + $second = DmMessage::whereObjectUri($alice->remote_url.'/statuses/2')->first(); + + expect($second->in_reply_to_id)->toBe($first->id) + ->and($second->conversation_id)->toBe($first->conversation_id); + }); + + it('follows the remote thread when it moves to a new context', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + dmDeliver($alice, dmNote($alice, '2', [$bob], [ + 'context' => 'https://remote.example/contexts/2', + 'conversation' => 'tag:remote.example,2026-09-21:objectId=2:objectType=Conversation', + ])); + + expect(DmConversation::count())->toBe(1) + ->and(DmConversation::first()->context_uri)->toBe('https://remote.example/contexts/2'); + }); +}); + +describe('edits', function () { + it('updates the text when the author edits a message', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + dmDeliver($alice, dmNote($alice, '1', [$bob], ['content' => '

fixed typo

']), 'Update'); + + $message = DmMessage::first(); + + expect(DmMessage::count())->toBe(1) + ->and($message->body)->toBe('fixed typo') + ->and($message->edited_at)->not->toBeNull(); + }); + + it('ignores an edit from someone who did not write the message', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + $mallory = dmRemoteProfile('mallory', 'remote.example'); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + dmDeliver($mallory, dmNote($alice, '1', [$bob], ['content' => '

hijacked

']), 'Update'); + + expect(DmMessage::first()->body)->toBe('hello there'); + }); +}); + +describe('deletes', function () { + it('removes a message when its author deletes it', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + dmDeliver($alice, dmNote($alice, '2', [$bob])); + + dmDeliver($alice, ['id' => $alice->remote_url.'/statuses/2', 'type' => 'Tombstone'], 'Delete'); + + $conversation = DmConversation::first(); + $first = DmMessage::whereObjectUri($alice->remote_url.'/statuses/1')->first(); + + expect(DmMessage::count())->toBe(1) + ->and($conversation->last_message_id)->toBe($first->id) + ->and(DmConversationParticipant::where('profile_id', $bob->id)->value('unread_count'))->toBe(1) + ->and(DB::table('dm_message_media')->count())->toBe(0); + }); + + it('removes the media of a deleted message', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob], [ + 'attachment' => [['type' => 'Document', 'mediaType' => 'image/jpeg', 'url' => 'https://remote.example/media/1.jpg']], + ])); + + expect(Media::count())->toBe(1); + + dmDeliver($alice, ['id' => $alice->remote_url.'/statuses/1', 'type' => 'Tombstone'], 'Delete'); + + expect(DmMessage::count())->toBe(0) + ->and(DB::table('dm_message_media')->count())->toBe(0); + + Queue::assertPushed(MediaDeletePipeline::class, 1); + }); + + it('ignores a delete from someone who did not write the message', function () { + $bob = dmProfile(dmLocalUser()); + $alice = dmRemoteProfile(); + $mallory = dmRemoteProfile('mallory', 'remote.example'); + dmSeedHosts(); + + dmDeliver($alice, dmNote($alice, '1', [$bob])); + dmDeliver($mallory, ['id' => $alice->remote_url.'/statuses/1', 'type' => 'Tombstone'], 'Delete'); + + expect(DmMessage::count())->toBe(1); + }); +}); diff --git a/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php b/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php new file mode 100644 index 000000000..3accb761a --- /dev/null +++ b/tests/Feature/DirectMessage/DirectMessageLegacyApiTest.php @@ -0,0 +1,270 @@ +withoutMiddleware(ThrottleRequests::class); + + config([ + 'instance.enable_cc' => false, + 'federation.activitypub.enabled' => true, + 'snowflake.datacenter_id' => 1, + 'snowflake.worker_id' => 1, + ]); +}); + +describe('thread endpoints', function () { + it('sends through thread/send and reads it back through thread', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + + $sent = $this->postJson('/api/v1.1/direct/thread/send', [ + 'to_id' => $bob->profile_id, + 'message' => 'hello bob', + 'type' => 'text', + ]); + + $sent->assertOk() + ->assertJsonPath('isAuthor', true) + ->assertJsonPath('type', 'text') + ->assertJsonPath('text', 'hello bob'); + + expect($sent->json('reportId'))->toBe($sent->json('id')) + ->and(DmConversation::count())->toBe(1); + + Passport::actingAs($bob, ['read', 'write']); + + $thread = $this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id); + + $thread->assertOk() + ->assertJsonPath('id', (string) $alice->profile_id) + ->assertJsonCount(1, 'messages') + ->assertJsonPath('messages.0.text', 'hello bob') + ->assertJsonPath('messages.0.isAuthor', false) + ->assertJsonPath('messages.0.seen', false); + + expect($thread->json('conversation_id'))->toBe((string) DmConversation::first()->id); + }); + + it('returns an empty thread for someone never messaged', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + + $this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id) + ->assertOk() + ->assertJsonCount(0, 'messages') + ->assertJsonPath('conversation_id', null); + }); + + it('uploads media with text as a single message', function () { + Storage::fake(config('filesystems.default')); + + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + + $this->post('/api/v1.1/direct/thread/media', [ + 'to_id' => $bob->profile_id, + 'file' => UploadedFile::fake()->image('cat.jpg', 100, 100), + 'message' => 'my cat', + ], ['Accept' => 'application/json'])->assertOk()->assertJsonPath('type', 'photo'); + + $message = DmMessage::with('media')->first(); + + expect(DmMessage::count())->toBe(1) + ->and($message->body)->toBe('my cat') + ->and($message->media)->toHaveCount(1) + ->and($message->media[0]->status_id)->toBeNull(); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id) + ->assertJsonPath('messages.0.text', 'my cat') + ->assertJsonPath('messages.0.type', 'photo') + ->assertJsonCount(1, 'messages.0.carousel'); + + expect($this->getJson('/api/v1.1/direct/thread?pid='.$alice->profile_id)->json('messages.0.media'))->not->toBeNull(); + }); + + it('marks a thread read and reports it as seen to the sender', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'one', 'type' => 'text'])->json('id'); + + Passport::actingAs($bob, ['read', 'write']); + $this->postJson('/api/v1.1/direct/thread/read', ['pid' => $alice->profile_id, 'sid' => $id]) + ->assertOk() + ->assertExactJson([$id]); + + expect(DmConversationParticipant::where('profile_id', $bob->profile_id)->value('unread_count'))->toBe(0); + + Passport::actingAs($alice, ['read', 'write']); + $this->getJson('/api/v1.1/direct/thread?pid='.$bob->profile_id)->assertJsonPath('messages.0.seen', true); + }); + + it('deletes by the id the thread handed out', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'oops', 'type' => 'text'])->json('reportId'); + + Passport::actingAs($bob, ['read', 'write']); + $this->deleteJson('/api/v1.1/direct/thread/message', ['id' => $id])->assertNotFound(); + + Passport::actingAs($alice, ['read', 'write']); + $this->deleteJson('/api/v1.1/direct/thread/message', ['id' => $id])->assertOk(); + + expect(DmMessage::count())->toBe(0); + }); + + it('mutes and unmutes a thread', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + + $this->postJson('/api/v1.1/direct/thread/mute', ['id' => $bob->profile_id])->assertOk(); + expect(DmConversationParticipant::where('profile_id', $alice->profile_id)->value('muted_at'))->not->toBeNull(); + + $this->postJson('/api/v1.1/direct/thread/unmute', ['id' => $bob->profile_id])->assertOk(); + expect(DmConversationParticipant::where('profile_id', $alice->profile_id)->value('muted_at'))->toBeNull(); + }); + + it('lets an older client report a message as a post', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + Passport::actingAs($alice, ['read', 'write']); + $id = $this->postJson('/api/v1.1/direct/thread/send', ['to_id' => $bob->profile_id, 'message' => 'spam', 'type' => 'text'])->json('reportId'); + + Passport::actingAs($bob, ['read', 'write']); + $this->postJson('/api/v1.1/report', ['report_type' => 'spam', 'object_type' => 'post', 'object_id' => $id])->assertOk(); + + expect(Report::first()->object_type)->toBe(DmMessage::class); + }); +}); + +describe('GET /api/v1/conversations', function () { + it('lists one to one conversations with a status shaped last message', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob)); + $message = $service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson('/api/v1/conversations') + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.id', (string) $conversation->id) + ->assertJsonPath('0.unread', true) + ->assertJsonPath('0.accounts.0.id', (string) $alice->profile_id) + ->assertJsonPath('0.last_status.id', (string) $message->id) + ->assertJsonPath('0.last_status.visibility', 'direct') + ->assertJsonPath('0.last_status.content', '

hello

') + ->assertJsonPath('0.last_status.account.id', (string) $alice->profile_id); + + $this->postJson("/api/v1/conversations/{$conversation->id}/read")->assertOk()->assertJsonPath('unread', false); + $this->deleteJson("/api/v1/conversations/{$conversation->id}")->assertOk(); + $this->getJson('/api/v1/conversations')->assertJsonCount(0); + }); + + it('only includes groups when asked', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + $dave = dmLocalUser(); + + $service = app(DirectMessageService::class); + $group = $service->findOrCreateConversation(dmProfile($alice), collect([dmProfile($bob), dmProfile($dave)])); + $service->sendMessage($group, dmProfile($alice), ['body' => 'hi all']); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson('/api/v1/conversations')->assertJsonCount(0); + $this->getJson('/api/v1/conversations?include_groups=1') + ->assertJsonCount(1) + ->assertJsonCount(2, '0.accounts'); + }); + + it('keeps requests out of the inbox', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(publicDm: false); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob)); + $service->sendMessage($conversation, dmProfile($alice), ['body' => 'hello']); + + Passport::actingAs($bob, ['read', 'write']); + + $this->getJson('/api/v1/conversations')->assertJsonCount(0); + $this->getJson('/api/v1/conversations?scope=requests')->assertJsonCount(1); + }); +}); + +describe('media housekeeping', function () { + it('does not let direct message media be attached to a post', function () { + $alice = dmLocalUser(); + $bob = dmLocalUser(); + + $media = Media::create([ + 'status_id' => null, + 'profile_id' => $alice->profile_id, + 'user_id' => $alice->id, + 'media_path' => 'public/m/test.jpg', + 'mime' => 'image/jpeg', + 'size' => 1000, + ]); + + $service = app(DirectMessageService::class); + $conversation = $service->findOrCreateDm(dmProfile($alice), dmProfile($bob)); + $service->sendMessage($conversation, dmProfile($alice), ['media' => collect([$media])]); + + Passport::actingAs($alice, ['read', 'write']); + + $this->postJson('/api/v1/statuses', ['media_ids' => [$media->id], 'status' => 'now public'])->assertStatus(400); + + expect($media->fresh()->status_id)->toBeNull(); + }); +}); diff --git a/tests/Feature/DirectMessage/helpers.php b/tests/Feature/DirectMessage/helpers.php new file mode 100644 index 000000000..4c3a33a34 --- /dev/null +++ b/tests/Feature/DirectMessage/helpers.php @@ -0,0 +1,130 @@ +create(array_merge(['created_at' => now()->subYear()], $attributes)); + $user->refresh(); + + UserSetting::updateOrCreate(['user_id' => $user->id], ['public_dm' => $publicDm]); + + return $user; + } + + function dmProfile(User $user): Profile + { + return Profile::findOrFail($user->profile_id); + } + + function dmRemoteProfile(string $username = 'alice', string $domain = 'remote.example'): Profile + { + $actor = "https://{$domain}/users/{$username}"; + + return Profile::factory()->remote()->create([ + 'domain' => $domain, + 'username' => "@{$username}@{$domain}", + 'remote_url' => $actor, + 'key_id' => "{$actor}#main-key", + 'inbox_url' => "{$actor}/inbox", + 'sharedInbox' => "https://{$domain}/inbox", + 'last_fetched_at' => now(), + ]); + } + + /** + * Seed the DNS and banned-domain caches so URL validation passes without + * a network lookup. Call after factories, the lazy refresh can flush the + * cache. + */ + function dmSeedHosts(array $hosts = ['remote.example', 'other.example']): void + { + $hosts[] = config('pixelfed.domain.app'); + + foreach ($hosts as $host) { + Cache::put('helpers:url:public-ips:'.hash('xxh128', $host), ['203.0.113.40'], 3600); + } + + Cache::put('instances:banned:domains', [], 1209600); + } + + function dmFollow(Profile $follower, Profile $target): void + { + Follower::create([ + 'profile_id' => $follower->id, + 'following_id' => $target->id, + 'local_profile' => $follower->domain === null, + 'local_following' => $target->domain === null, + ]); + } + + /** + * A direct Note the way Mastodon sends one: addressed to people only, + * a Mention for each of them, and the thread identifiers. + * + * @param array $recipients + */ + function dmNote(Profile $author, string $path, array $recipients, array $overrides = []): array + { + $id = $author->remote_url.'/statuses/'.$path; + $to = array_map(fn (Profile $profile) => $profile->permalink(), $recipients); + $mentions = implode(' ', array_map( + fn (Profile $profile) => '@'.ltrim(explode('@', ltrim($profile->username, '@'))[0], '@').'', + $recipients + )); + + return array_merge([ + 'id' => $id, + 'type' => 'Note', + 'attributedTo' => $author->remote_url, + 'url' => "https://{$author->domain}/@user/{$path}", + 'content' => "

{$mentions} hello there

", + 'published' => now()->subMinute()->toAtomString(), + 'inReplyTo' => null, + 'to' => $to, + 'cc' => [], + 'sensitive' => false, + 'conversation' => "tag:{$author->domain},2026-09-21:objectId=1:objectType=Conversation", + 'context' => "https://{$author->domain}/contexts/1", + 'attachment' => [], + 'tag' => array_map(fn (Profile $profile) => [ + 'type' => 'Mention', + 'href' => $profile->permalink(), + 'name' => '@'.ltrim($profile->username, '@'), + ], $recipients), + ], $overrides); + } + + function dmDeliver(Profile $actor, array $object, string $type = 'Create'): void + { + $payload = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $object['id'].'/activity', + 'type' => $type, + 'actor' => $actor->remote_url, + 'object' => $object, + ]; + + if ($type === 'Create') { + $payload['to'] = $object['to'] ?? []; + $payload['cc'] = $object['cc'] ?? []; + } + + $headers = [ + 'signature' => ['keyId="'.$actor->key_id.'",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="dGVzdA=="'], + 'date' => [now()->toRfc7231String()], + ]; + + (new Inbox($headers, null, $payload))->handle(); + } +}