Merge pull request #7407 from pixelfed/staging

Fix DMs
pull/7409/head^2
dansup 2 days ago committed by GitHub
commit bb1e1bf7ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,314 @@
<?php
namespace App\Console\Commands\DirectMessage;
use App\Models\DmConversation;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Services\SnowflakeService;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class BackfillConversations extends Command
{
protected $signature = 'dm:backfill-conversations
{--chunk=500 : Legacy rows to convert per batch}
{--full : Start from the first legacy row instead of resuming}
{--force : Do not ask for confirmation}';
protected $description = 'Convert legacy direct messages (direct_messages + direct statuses) into conversations';
/**
* Nothing is removed: the legacy rows and their statuses stay where they
* are. Each message keeps the id of the status it used to be, so it sorts
* correctly against messages sent after the upgrade and the ids older
* clients already hold keep working. Safe to stop and run again.
*/
public function handle(): int
{
$total = DB::table('direct_messages')->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;
}
}

@ -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();

@ -0,0 +1,37 @@
<?php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* A direct message action the user is not allowed to take, or asked for in a
* way that cannot work. The message is safe to show and the code is the HTTP
* status it renders as.
*/
class DirectMessageException extends Exception
{
public function __construct(string $message, protected int $status = 422)
{
parent::__construct($message, $status);
}
public function status(): int
{
return $this->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;
}
}

@ -0,0 +1,167 @@
<?php
namespace App\Federation\ActivityBuilders;
use App\Models\DmConversation;
use App\Models\DmMessage;
use App\Models\Media;
use App\Models\Profile;
use App\Services\DirectMessagePayloadService;
use Illuminate\Support\Collection;
class DirectMessageActivityBuilder
{
public const CONTEXT = [
'https://www.w3.org/ns/activitystreams',
[
'ostatus' => 'http://ostatus.org#',
'conversation' => 'ostatus:conversation',
'sensitive' => 'as:sensitive',
'toot' => 'http://joinmastodon.org/ns#',
'blurhash' => 'toot:blurhash',
],
];
/**
* @param Collection<int, Profile> $others Everyone in the conversation except the sender
* @return array<string, mixed>
*/
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<int, Profile> $others
* @return array<string, mixed>
*/
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<int, Profile> $others
* @return array<string, mixed>
*/
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<string, mixed>
*/
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;
}
}

@ -0,0 +1,429 @@
<?php
namespace App\Federation\Handlers;
use App\Exceptions\DirectMessageException;
use App\Federation\Validators\DirectMessageValidator;
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\Services\DirectMessageService;
use App\Services\FollowersSyncService;
use App\Util\ActivityPub\Helpers;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Stevebauman\Purify\Facades\Purify;
use Throwable;
class DirectMessageHandler
{
public const MAX_BODY_LENGTH = 5000;
public function __construct(protected DirectMessageService $service) {}
/**
* Store a direct Note delivered by $actor.
*
* The conversation is whoever the Note is addressed to plus its author,
* so one recipient is a 1:1 chat and several are a group. Returns null
* when the message is dropped, which is never an error: the sender is
* not told about blocks, privacy settings or limits.
*/
public function handleCreate(array $object, Profile $actor): ?DmMessage
{
if ($actor->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<int, Profile>|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<int, Profile> $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<int, Media>
*/
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('/<br\s*\/?>/i', "\n", $html);
$html = preg_replace('/<\/p>\s*<p[^>]*>/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);
}
}

@ -0,0 +1,133 @@
<?php
namespace App\Federation\Validators;
use App\Models\Profile;
use App\Util\ActivityPub\Helpers;
class DirectMessageValidator
{
public const PUBLIC_URIS = [
'https://www.w3.org/ns/activitystreams#Public',
'as:Public',
'Public',
];
/**
* Everyone the object is addressed to, as a flat list of ids.
*
* @return array<int, string>
*/
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;
}
}

@ -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');
}

@ -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) {

@ -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'),

@ -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;

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

@ -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();
}
}

@ -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;

@ -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');
}

@ -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');
}

@ -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'));

@ -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) {

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

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

@ -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();

@ -0,0 +1,117 @@
<?php
namespace App\Jobs\Federation;
use App\Exceptions\InvalidDeliveryDestinationException;
use App\Models\Profile;
use App\Services\ActivityPubDeliveryService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
/**
* Delivers one direct message activity (Create or Delete) to one inbox.
*
* A message that is lost to a timeout or a 503 is simply never seen by the
* person it was for, so a temporary failure is retried instead of dropped.
*/
class DeliverDirectMessageActivity implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Seconds to wait before each retry.
*
* @var array<int, int>
*/
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<string, mixed> $activity
*/
public function __construct(
protected int $fromProfileId,
protected string $inbox,
protected array $activity
) {}
public function inbox(): string
{
return $this->inbox;
}
/**
* @return array<string, mixed>
*/
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);
}
}

@ -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

@ -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');

@ -0,0 +1,103 @@
<?php
namespace App\Models;
use App\HasSnowflakePrimary;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $type
* @property string $participants_hash
* @property string|null $name
* @property string|null $context_uri
* @property string|null $conversation_uri
* @property int|null $created_by_profile_id
* @property int|null $last_message_id
* @property Carbon|null $last_message_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class DmConversation extends Model
{
use HasSnowflakePrimary;
public const TYPE_DM = 'dm';
public const TYPE_GROUP = 'group';
public $incrementing = false;
protected $guarded = [];
protected function casts(): array
{
return [
'last_message_at' => 'datetime',
];
}
/**
* A conversation is its set of participants. The same people always land
* in the same conversation, whichever server started the thread.
*
* @param array<int, int|string> $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);
}
}

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $conversation_id
* @property int $profile_id
* @property string $state
* @property int|null $last_read_message_id
* @property int $unread_count
* @property Carbon|null $last_activity_at
* @property Carbon|null $muted_at
* @property Carbon|null $hidden_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class DmConversationParticipant extends Model
{
public const STATE_ACTIVE = 'active';
public const STATE_REQUEST = 'request';
public const STATE_LEFT = 'left';
protected $guarded = [];
protected function casts(): array
{
return [
'unread_count' => '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;
}
}

@ -0,0 +1,129 @@
<?php
namespace App\Models;
use App\HasSnowflakePrimary;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $conversation_id
* @property int $profile_id
* @property string $type
* @property string|null $body
* @property array|null $entities
* @property array|null $meta
* @property bool $is_sensitive
* @property string|null $ap_object_uri
* @property string|null $ap_object_hash
* @property int|null $in_reply_to_id
* @property int|null $status_id
* @property int|null $legacy_dm_id
* @property Carbon|null $edited_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
*/
class DmMessage extends Model
{
use HasSnowflakePrimary, SoftDeletes;
public const TYPE_TEXT = 'text';
public const TYPE_EMOJI = 'emoji';
public const TYPE_LINK = 'link';
public const TYPE_PHOTO = 'photo';
public const TYPE_PHOTOS = 'photos';
public const TYPE_VIDEO = 'video';
public const TYPE_VIDEOS = 'videos';
public const TYPE_MEDIA = 'media';
public const TYPE_STORY_REACT = 'story:react';
public const TYPE_STORY_COMMENT = 'story:comment';
public $incrementing = false;
protected $guarded = [];
protected function casts(): array
{
return [
'entities' => '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');
}
}

@ -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);

@ -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';

@ -0,0 +1,318 @@
<?php
namespace App\Services;
use App\Models\DmConversation;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Transformer\Api\MediaTransformer;
use App\Util\Lexer\Autolink;
use Illuminate\Support\Collection;
class DirectMessagePayloadService
{
/*
|--------------------------------------------------------------------------
| Conversations
|--------------------------------------------------------------------------
*/
/**
* @param Collection<int, DmConversationParticipant> $rows The viewer's participant rows, with `conversation` loaded
* @return array<int, array<string, mixed>>
*/
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<int, DmConversationParticipant>|null $members
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<int, DmMessage> $messages
* @return array<int, array<string, mixed>>
*/
public function messages(Collection $messages, int $viewerId): array
{
return $messages->map(fn (DmMessage $message) => $this->message($message, $viewerId))->values()->all();
}
/**
* @return array<int, array<string, mixed>>
*/
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<string, mixed>
*/
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<int, DmConversationParticipant> $members
* @return array<string, mixed>|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<int, array<string, mixed>> $accounts
* @return array<string, mixed>
*/
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 '<p>'.nl2br(Autolink::create()->autolink($escaped), false).'</p>';
}
/**
* @return array<int, int>
*/
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));
}
}

@ -0,0 +1,836 @@
<?php
namespace App\Services;
use App\Exceptions\DirectMessageException;
use App\Federation\ActivityBuilders\DirectMessageActivityBuilder;
use App\Jobs\Federation\DeliverDirectMessageActivity;
use App\Jobs\PushNotificationPipeline\MentionPushNotifyPipeline;
use App\Models\DmConversation;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Models\Media;
use App\Models\Notification;
use App\Models\Profile;
use App\Models\User;
use App\Util\ActivityPub\Helpers;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class DirectMessageService
{
public function canUseDirectMessages(User $user): bool
{
if ($user->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<int, Profile> $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<int, int>
*/
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<int, int|string> $ids
* @return Collection<int, Media>
*/
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<int, Profile> $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<int, Profile> $profiles
* @return array<int, string>
*/
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();
}
}

@ -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) {

@ -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');
}
}
}
}
}

@ -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);

@ -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);
}
}

@ -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)) {

@ -0,0 +1,76 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Group conversations
|--------------------------------------------------------------------------
|
| A conversation is identified by its set of participants. The limit counts
| everyone in the conversation, including the person who started it.
| Inbound messages addressed to more actors than this are dropped.
|
*/
'groups' => [
'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),
],
];

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dm_conversations', function (Blueprint $table) {
$table->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');
}
};

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dm_conversation_participants', function (Blueprint $table) {
$table->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');
}
};

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dm_messages', function (Blueprint $table) {
$table->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');
}
};

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dm_message_media', function (Blueprint $table) {
$table->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');
}
};

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Small instances get their legacy direct messages converted right here.
* Anything bigger is left to `php artisan dm:backfill-conversations`,
* which is chunked, resumable and safe to run while the app is live.
*/
public function up(): void
{
if (! Schema::hasTable('direct_messages')) {
return;
}
$threshold = (int) config('dm.backfill.inline_threshold', 25000);
$count = DB::table('direct_messages')->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
{
//
}
};

@ -7,7 +7,7 @@
<img v-if="!convo.isAuthor && !hideAvatars" class="mr-3 shadow msg-avatar" :src="thread.avatar" alt="avatar" width="50" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
<div class="media-body">
<p v-if="convo.type == 'photo'" class="pill-to p-0 shadow">
<p v-if="convo.type == 'photo' || convo.type == 'photos'" class="pill-to p-0 shadow">
<img
:src="convo.media"
class="media-embed"
@ -32,7 +32,7 @@
</div>
</div>
</div>
<p v-else-if="convo.type == 'video'" class="pill-to p-0 shadow mb-0" style="line-height: 0;">
<p v-else-if="convo.type == 'video' || convo.type == 'videos'" class="pill-to p-0 shadow mb-0" style="line-height: 0;">
<video :src="convo.media" class="media-embed" style="border-radius:20px;" controls>
</video>
<!-- <span class="d-block bg-primary d-flex align-items-center justify-content-center" style="width:200px;height: 110px;border-radius: 20px;">
@ -64,6 +64,9 @@
<p v-else :class="[largerText ? 'pill-to shadow larger-text text-break':'pill-to shadow text-break']">
{{convo.text}}
</p>
<p v-if="hasMediaCaption" :class="[largerText ? 'pill-to shadow larger-text text-break mt-2':'pill-to shadow text-break mt-2']">
{{convo.text}}
</p>
<p v-if="convo.type == 'story:react'" class="small text-muted mb-0 ml-0">
<span class="font-weight-bold">{{ convo.meta.story_actor_username }}</span> reacted your story
</p>
@ -136,6 +139,14 @@
}
},
computed: {
hasMediaCaption() {
return ['photo', 'photos', 'video', 'videos', 'media'].includes(this.convo.type)
&& !!this.convo.text
&& this.convo.text.length > 0;
}
},
methods: {
truncate(t) {
return _.truncate(t);

@ -42,7 +42,7 @@
<div v-if="!convo.isAuthor" class="media d-inline-flex mb-0">
<img v-if="!hideAvatars" class="mr-3 mt-2 rounded-circle img-thumbnail" :src="thread.avatar" alt="avatar" width="32" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
<div class="media-body">
<p v-if="convo.type == 'photo'" class="pill-to p-0 shadow">
<p v-if="convo.type == 'photo' || convo.type == 'photos'" class="pill-to p-0 shadow">
<img :src="convo.media" width="140" style="border-radius:20px;" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
</p>
<div v-else-if="convo.type == 'link'" class="media d-inline-flex mb-0 cursor-pointer">
@ -64,7 +64,7 @@
</div>
</div>
</div>
<p v-else-if="convo.type == 'video'" class="pill-to p-0 shadow">
<p v-else-if="convo.type == 'video' || convo.type == 'videos'" class="pill-to p-0 shadow">
<!-- <video :src="convo.media" width="140px" style="border-radius:20px;"></video> -->
<span class="d-block bg-primary d-flex align-items-center justify-content-center" style="width:200px;height: 110px;border-radius: 20px;">
<div class="text-center">
@ -95,6 +95,9 @@
<p v-else :class="[largerText ? 'pill-to shadow larger-text text-break':'pill-to shadow text-break']">
{{convo.text}}
</p>
<p v-if="hasMediaCaption(convo)" :class="[largerText ? 'pill-to shadow larger-text text-break mt-2':'pill-to shadow text-break mt-2']">
{{convo.text}}
</p>
<p v-if="convo.type == 'story:react'" class="small text-muted mb-0 ml-0">
<span class="font-weight-bold">{{ convo.meta.story_actor_username }}</span> reacted your story
</p>
@ -107,7 +110,7 @@
</div>
<div v-else class="media d-inline-flex float-right mb-0 mr-2">
<div class="media-body">
<p v-if="convo.type == 'photo'" class="pill-from p-0 shadow">
<p v-if="convo.type == 'photo' || convo.type == 'photos'" class="pill-from p-0 shadow">
<img :src="convo.media" width="140" style="border-radius:20px;" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
</p>
<div v-else-if="convo.type == 'link'" class="media d-inline-flex float-right mb-0 cursor-pointer">
@ -129,7 +132,7 @@
</div>
</div>
</div>
<p v-else-if="convo.type == 'video'" class="pill-from p-0 shadow">
<p v-else-if="convo.type == 'video' || convo.type == 'videos'" class="pill-from p-0 shadow">
<!-- <video :src="convo.media" width="140px" style="border-radius:20px;"></video> -->
<span class="rounded-pill bg-primary d-flex align-items-center justify-content-center" style="width:200px;height: 110px">
<div class="text-center">
@ -160,6 +163,9 @@
<p v-else :class="[largerText ? 'pill-from shadow larger-text text-break':'pill-from shadow text-break']">
{{convo.text}}
</p>
<p v-if="hasMediaCaption(convo)" :class="[largerText ? 'pill-from shadow larger-text text-break mt-2':'pill-from shadow text-break mt-2']">
{{convo.text}}
</p>
<p v-if="convo.type == 'story:react'" class="small text-muted text-right mb-0 mr-0">
You reacted to <span class="font-weight-bold">{{ convo.meta.story_username }}</span>'s story
</p>
@ -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;

@ -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) {

@ -1,8 +1,15 @@
<?php
use App\Models\DirectMessage;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Models\Profile;
use App\Models\User;
use App\Models\UserSetting;
use App\Services\DirectMessageService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Laravel\Passport\Passport;
uses(LazilyRefreshDatabase::class);
@ -12,90 +19,106 @@ uses(LazilyRefreshDatabase::class);
| Direct Message read endpoint
|--------------------------------------------------------------------------
|
| DirectMessageController@read previously fetched every matching row and
| saved each one in a loop. It now performs a single bulk update. These
| tests lock in the observable behaviour: matching messages are marked
| read and their ids are returned.
| DirectMessageController@read is the legacy way to mark a thread read. It
| is addressed by the other person's profile id and a message id, and
| returns the ids it marked. Read state now lives on the conversation
| participant, so these tests lock in the observable behaviour: messages
| from that sender at or after the given id are reported and the thread is
| read up to the newest of them.
|
*/
function makeDm(int $toId, int $fromId, int $statusId): DirectMessage
beforeEach(function () {
Redis::spy();
Queue::fake();
$this->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);
});
});

@ -0,0 +1,493 @@
<?php
use App\Federation\ActivityBuilders\DirectMessageActivityBuilder;
use App\Jobs\Federation\DeliverDirectMessageActivity;
use App\Models\DmConversation;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Models\Media;
use App\Models\Notification;
use App\Models\Report;
use App\Models\User;
use App\Models\UserFilter;
use App\Services\DirectMessageService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
use Laravel\Passport\Passport;
require_once __DIR__.'/helpers.php';
uses(LazilyRefreshDatabase::class);
beforeEach(function () {
Redis::spy();
Queue::fake();
Http::fake();
$this->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 <b>all</b>\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("<p>hello &lt;b&gt;all&lt;/b&gt;<br>\nsecond line</p>");
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('<p>look</p>');
});
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);
});
});

@ -0,0 +1,235 @@
<?php
use App\Jobs\Federation\DeliverDirectMessageActivity;
use App\Models\DirectMessage;
use App\Models\DmConversation;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Models\Media;
use App\Models\Notification;
use App\Models\Profile;
use App\Models\Status;
use App\Models\UserFilter;
use App\Services\DirectMessageService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
require_once __DIR__.'/helpers.php';
uses(LazilyRefreshDatabase::class);
beforeEach(function () {
Redis::spy();
Queue::fake();
Http::fake();
config(['snowflake.datacenter_id' => 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 &amp; 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);
});
});

@ -0,0 +1,610 @@
<?php
use App\Federation\Handlers\DirectMessageHandler;
use App\Federation\Validators\DirectMessageValidator;
use App\Jobs\MediaPipeline\MediaDeletePipeline;
use App\Models\DmConversation;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Models\Media;
use App\Models\Notification;
use App\Models\Status;
use App\Models\UserDomainBlock;
use App\Models\UserFilter;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
require_once __DIR__.'/helpers.php';
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Inbound direct messages
|--------------------------------------------------------------------------
|
| Invariant: a Note addressed only to people is a direct message and never
| anything else. It is stored in the conversation formed by its author and
| the people it names, or it is dropped. It must never reach the code that
| stores posts and replies, because that code files every non-public Note as
| followers-only.
|
*/
beforeEach(function () {
Redis::spy();
Queue::fake();
Http::fake();
config([
'instance.enable_cc' => 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' => '<p>look at this</p>',
'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 = '<p><span class="h-card"><a href="https://pixelfed.test/users/bob">@<span>bob</span></a></span> '
.'<span class="h-card"><a href="https://other.example/users/carol">@<span>carol</span></a></span> first line<br>second line</p>'
.'<p>new paragraph &amp; more</p>';
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('<p>@bob have you met @carol@other.example yet</p>'))
->toBe('have you met @carol@other.example yet');
});
it('does not keep markup or script content', function () {
expect(DirectMessageHandler::plainText('<p>hi<script>alert(1)</script> <b>there</b></p>'))->toBe('hi there');
});
it('returns null when nothing is left', function () {
expect(DirectMessageHandler::plainText('<p><span class="h-card"><a href="#">@<span>bob</span></a></span></p>'))->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' => '<p>fixed typo</p>']), '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' => '<p>hijacked</p>']), '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);
});
});

@ -0,0 +1,270 @@
<?php
use App\Models\DmConversation;
use App\Models\DmConversationParticipant;
use App\Models\DmMessage;
use App\Models\Media;
use App\Models\Report;
use App\Services\DirectMessageService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use Laravel\Passport\Passport;
require_once __DIR__.'/helpers.php';
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Legacy thread endpoints and the Mastodon conversations API
|--------------------------------------------------------------------------
|
| The Blade UI and the older mobile app address a thread by the other
| person's profile id. Those endpoints keep their request and response
| shapes and now read and write conversations.
|
*/
beforeEach(function () {
Redis::spy();
Queue::fake();
Http::fake();
$this->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', '<p>hello</p>')
->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();
});
});

@ -0,0 +1,130 @@
<?php
use App\Models\Follower;
use App\Models\Profile;
use App\Models\User;
use App\Models\UserSetting;
use App\Util\ActivityPub\Inbox;
use Illuminate\Support\Facades\Cache;
if (! function_exists('dmLocalUser')) {
/**
* A local user. By default they take messages from everyone, so a test
* about requests has to ask for that explicitly.
*/
function dmLocalUser(bool $publicDm = true, array $attributes = []): User
{
$user = User::factory()->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<int, Profile> $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) => '<span class="h-card"><a href="'.$profile->permalink().'" class="u-url mention">@<span>'.ltrim(explode('@', ltrim($profile->username, '@'))[0], '@').'</span></a></span>',
$recipients
));
return array_merge([
'id' => $id,
'type' => 'Note',
'attributedTo' => $author->remote_url,
'url' => "https://{$author->domain}/@user/{$path}",
'content' => "<p>{$mentions} hello there</p>",
'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();
}
}
Loading…
Cancel
Save