Fix reply visibility in context endpoints

pull/7614/head
Daniel Supernault 3 weeks ago
parent 3949724a67
commit de05bbc9f6
No known key found for this signature in database
GPG Key ID: 23740873EE6F76A1

@ -3409,28 +3409,36 @@ class ApiV1Controller extends Controller
}
if ($status['replies_count']) {
$parentOwnerId = $status['account']['id'];
$parentScope = $status['visibility'];
$descendants = DB::table('statuses')
->select(['id', 'profile_id', 'scope', 'local'])
->where('in_reply_to_id', $id)
->limit(20)
->pluck('id')
->map(function ($sid) use ($pe, $pid) {
return $pe
? StatusService::get(
$sid,
false,
false,
$pid
)
: StatusService::getMastodon(
$sid,
false,
->whereNull('deleted_at')
->whereIn('scope', ['public', 'unlisted', 'private'])
->orderBy('id')
->limit(100)
->get()
->filter(function ($row) use ($filters, $pid, $parentOwnerId, $parentScope) {
return ! in_array($row->profile_id, $filters) &&
StatusService::isReplyVisibleTo(
$row->profile_id,
$row->scope,
$row->local,
$parentOwnerId,
$parentScope,
$pid
);
})
->filter(function ($post) use ($filters) {
return $post &&
isset($post['account']['id']) &&
! in_array($post['account']['id'], $filters);
->take(20)
->map(function ($row) use ($pe) {
return $pe
? StatusService::get($row->id, false)
: StatusService::getMastodon($row->id, false);
})
->filter(function ($post) {
return $post && isset($post['account']['id']);
})
->map(function ($status) use ($pid) {
$status['favourited'] = LikeService::liked(

@ -10,7 +10,6 @@ use App\Models\Media;
use App\Models\User;
use App\Models\UserSetting;
use App\Services\AccountService;
use App\Services\FollowerService;
use App\Services\InstanceService;
use App\Services\LikeService;
use App\Services\MediaBlocklistService;
@ -360,7 +359,7 @@ class ApiV2Controller extends Controller
$user = $request->user();
$pid = $user->profile_id;
$status = StatusService::getMastodon($id, false);
$status = StatusService::getMastodon($id, false, $pid);
$pe = $request->has(self::PF_API_ENTITY_KEY);
if (! $status || ! isset($status['account'])) {
@ -373,19 +372,6 @@ class ApiV2Controller extends Controller
abort_if(in_array($domain, InstanceService::getBannedDomains()), 404);
}
// Visibility check
if (intval($status['account']['id']) !== intval($user->profile_id)) {
if ($status['visibility'] == 'private') {
if (! FollowerService::follows($user->profile_id, $status['account']['id'])) {
return response('', 404);
}
} else {
if (! in_array($status['visibility'], ['public', 'unlisted'])) {
return response('', 404);
}
}
}
// Get request parameters
$limit = min((int) $request->input('limit', 20), 40); // Max 40 items
$maxId = $request->input('max_id');
@ -394,7 +380,7 @@ class ApiV2Controller extends Controller
$ancestorsLimit = min((int) $request->input('ancestors_limit', 10), 20); // Max 20 ancestors
$ancestors = $this->getAncestors($id, $ancestorsLimit, $pe, $pid);
$descendants = $this->getDescendantsPaginated($id, $limit, $maxId, $minId, $sinceId, $pe, $pid);
$descendants = $this->getDescendantsPaginated($status, $limit, $maxId, $minId, $sinceId, $pe, $pid);
$res = [
'ancestors' => $ancestors['data'],
@ -439,8 +425,8 @@ class ApiV2Controller extends Controller
// Filter at application level (more efficient than SQL NOT IN on large tables)
if (! in_array($parent->profile_id, $filters)) {
$parentStatus = $pe ?
StatusService::get($parent->id, false) :
StatusService::getMastodon($parent->id, false);
StatusService::get($parent->id, false, false, $pid) :
StatusService::getMastodon($parent->id, false, $pid);
if ($parentStatus && isset($parentStatus['account'])) {
// Add interaction status
@ -462,13 +448,19 @@ class ApiV2Controller extends Controller
* Get descendants (replies) with efficient cursor pagination
* Optimized for existing indexes: statuses_in_reply_to_id_index
*/
private function getDescendantsPaginated($statusId, $limit, $maxId, $minId, $sinceId, $pe, $pid): array
private function getDescendantsPaginated($parent, $limit, $maxId, $minId, $sinceId, $pe, $pid): array
{
$statusId = $parent['id'];
$parentOwnerId = $parent['account']['id'];
$parentScope = $parent['visibility'];
// Build efficient query using existing indexes
// Uses statuses_in_reply_to_id_index for fast filtering
$query = DB::table('statuses')
->select(['id', 'profile_id'])
->select(['id', 'profile_id', 'scope', 'local'])
->where('in_reply_to_id', $statusId)
->whereNull('deleted_at')
->whereIn('scope', ['public', 'unlisted', 'private'])
->orderBy('id', 'desc'); // Snowflake IDs are chronologically ordered
// Apply cursor pagination
@ -496,12 +488,23 @@ class ApiV2Controller extends Controller
$filters = UserFilterService::filters($pid);
// Transform and filter results
$descendants = $results->map(function ($row) use ($pe, $pid, $filters) {
$descendants = $results->map(function ($row) use ($pe, $pid, $filters, $parentOwnerId, $parentScope) {
// Skip if user is filtered (post-processing is more efficient here)
if (in_array($row->profile_id, $filters)) {
return null;
}
if (! StatusService::isReplyVisibleTo(
$row->profile_id,
$row->scope,
$row->local,
$parentOwnerId,
$parentScope,
$pid
)) {
return null;
}
$status = $pe ?
StatusService::get($row->id, false) :
StatusService::getMastodon($row->id, false);
@ -517,20 +520,17 @@ class ApiV2Controller extends Controller
return $status;
})->filter()->values();
// If we filtered out results and don't have enough, we might need more
// This is a trade-off: either multiple queries or over-fetching
$actualHasMore = $hasMore || ($results->count() === $limit && $descendants->count() < $limit);
// Build pagination info
// Cursors come from the raw rows, not the filtered ones, so a page
// where every reply was hidden from this viewer still paginates
$pagination = [];
if ($descendants->isNotEmpty()) {
$pagination['max_id'] = $descendants->last()['id'];
$pagination['min_id'] = $descendants->first()['id'];
$pagination['has_more'] = $actualHasMore;
if ($results->isNotEmpty()) {
$pagination['max_id'] = (string) $results->last()->id;
$pagination['min_id'] = (string) $results->first()->id;
$pagination['has_more'] = $hasMore;
// Generate next/prev URLs if applicable
if ($actualHasMore) {
if ($hasMore) {
$pagination['next_url'] = route('api.v2.status.context', $statusId).
'?'.http_build_query(['max_id' => $pagination['max_id'], 'limit' => $limit]);
}
@ -598,19 +598,17 @@ class ApiV2Controller extends Controller
$pe = $request->has(self::PF_API_ENTITY_KEY);
// Validate status exists and user can view it
$status = StatusService::getMastodon($id, false);
$status = StatusService::getMastodon($id, false, $pid);
if (! $status || ! isset($status['account'])) {
return response('', 404);
}
// Same visibility checks as above...
$limit = min((int) $request->input('limit', 20), 40);
$maxId = $request->input('max_id');
$minId = $request->input('min_id');
$sinceId = $request->input('since_id');
$descendants = $this->getDescendantsPaginated($id, $limit, $maxId, $minId, $sinceId, $pe, $pid);
$descendants = $this->getDescendantsPaginated($status, $limit, $maxId, $minId, $sinceId, $pe, $pid);
return $this->json($descendants);
}
@ -630,7 +628,7 @@ class ApiV2Controller extends Controller
$pe = $request->has(self::PF_API_ENTITY_KEY);
// Validate status exists and user can view it
$status = StatusService::getMastodon($id, false);
$status = StatusService::getMastodon($id, false, $pid);
if (! $status || ! isset($status['account'])) {
return response('', 404);
}

@ -72,8 +72,13 @@ class StatusService
if ($viewerProfileId !== null) {
$ownerProfileId = $res['_pid'] ?? null;
$visibility = $res['visibility'] ?? null;
$addressee = $res['in_reply_to_account_id'] ?? null;
if (! self::isVisibleTo(
$isAddressee = $visibility === 'private' &&
$addressee &&
(int) $addressee === (int) $viewerProfileId;
if (! $isAddressee && ! self::isVisibleTo(
$ownerProfileId,
$visibility,
$viewerProfileId
@ -221,6 +226,46 @@ class StatusService
}
}
public static function isReplyVisibleTo(
$replyOwnerProfileId,
$replyScope,
$replyIsLocal,
$parentOwnerProfileId,
$parentScope,
$viewerProfileId
): bool {
if (! $replyOwnerProfileId || ! $viewerProfileId) {
return false;
}
if (in_array($replyScope, ['public', 'unlisted'], true)) {
return true;
}
if ($replyScope !== 'private') {
return false;
}
$replyOwnerProfileId = (int) $replyOwnerProfileId;
$viewerProfileId = (int) $viewerProfileId;
if (
$replyOwnerProfileId === $viewerProfileId ||
(int) $parentOwnerProfileId === $viewerProfileId
) {
return true;
}
if ($parentScope === 'private' && $replyIsLocal) {
return true;
}
return (bool) FollowerService::follows(
(string) $viewerProfileId,
(string) $replyOwnerProfileId
);
}
public static function clampReplyVisibility(
$replyVisibility,
$parentVisibility

Loading…
Cancel
Save