mirror of https://github.com/pixelfed/pixelfed
Fix federation comment threading
parent
9909bd0d33
commit
80f792038c
@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\FixBugs;
|
||||
|
||||
use App\Jobs\HomeFeedPipeline\FeedRemoveRemotePipeline;
|
||||
use App\Jobs\StatusPipeline\RemoteStatusDelete;
|
||||
use App\Models\Status;
|
||||
use App\Services\NetworkTimelineService;
|
||||
use App\Services\SnowflakeService;
|
||||
use App\Services\StatusService;
|
||||
use App\Util\ActivityPub\Helpers;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Repairs remote replies that were stored as top-level statuses.
|
||||
*
|
||||
* Until the reply threading fix, a remote reply whose parent could not be
|
||||
* resolved (failed fetch, depth limit, blocked author) was stored with
|
||||
* in_reply_to_id null, and deleting a status nulled in_reply_to_id on all of
|
||||
* its replies. Nothing in the row says it was a reply, so each candidate is
|
||||
* re-fetched from its origin and its inReplyTo is read from the source.
|
||||
*
|
||||
* Without options this is a read-only report that fetches each candidate but
|
||||
* writes nothing.
|
||||
*/
|
||||
class FixOrphanedReplies extends Command
|
||||
{
|
||||
protected $signature = 'fix:orphaned-replies
|
||||
{--days=7 : How many days back to look, by status id}
|
||||
{--media : Also check photo and video statuses, not only text ones. Far more candidates, every remote post in the window is fetched}
|
||||
{--limit=0 : Stop after this many candidates, 0 for no limit}
|
||||
{--delay=100 : Milliseconds to wait between origin fetches}
|
||||
{--fix : Relink orphans whose parent resolves. May fetch and store missing parents}
|
||||
{--prune : With --fix, delete orphans whose parent cannot be resolved or refuses the reply}';
|
||||
|
||||
protected $description = 'Find remote replies that were stored without in_reply_to_id, relink them to their parent, and optionally remove the unrecoverable ones.';
|
||||
|
||||
private const array MEDIA_TYPES = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
|
||||
|
||||
private array $stats = [
|
||||
'checked' => 0,
|
||||
'top_level' => 0,
|
||||
'unreachable' => 0,
|
||||
'orphans' => 0,
|
||||
'parent_known' => 0,
|
||||
'relinked' => 0,
|
||||
'pruned' => 0,
|
||||
'unrecoverable' => 0,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = max(1, (int) $this->option('days'));
|
||||
$limit = max(0, (int) $this->option('limit'));
|
||||
$delay = max(0, (int) $this->option('delay'));
|
||||
$fix = (bool) $this->option('fix');
|
||||
$prune = (bool) $this->option('prune');
|
||||
|
||||
if ($prune && ! $fix) {
|
||||
$this->error('--prune only works together with --fix.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($prune && $this->input->isInteractive() && ! $this->confirm(
|
||||
'Orphans whose parent cannot be resolved right now will be deleted, including ones whose parent server is only temporarily down. Continue?'
|
||||
)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$types = $this->option('media')
|
||||
? array_merge(['text'], self::MEDIA_TYPES)
|
||||
: ['text'];
|
||||
|
||||
// Status ids are snowflakes, so a date maps to an id and the scan is
|
||||
// a bounded range on an index instead of a walk over created_at.
|
||||
$minId = SnowflakeService::byDate(now()->subDays($days));
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s remote %s statuses without a parent from the last %d day(s)...',
|
||||
$fix ? 'Repairing' : 'Checking (read-only)',
|
||||
implode(', ', $types),
|
||||
$days
|
||||
));
|
||||
|
||||
Status::query()
|
||||
->where('id', '>=', $minId)
|
||||
->whereNotNull('uri')
|
||||
->whereNull('in_reply_to_id')
|
||||
->whereNull('reblog_of_id')
|
||||
->whereIn('type', $types)
|
||||
->whereIn('scope', ['public', 'unlisted', 'private'])
|
||||
->chunkById(200, function ($statuses) use ($limit, $delay, $fix, $prune) {
|
||||
foreach ($statuses as $status) {
|
||||
if ($limit && $this->stats['checked'] >= $limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->check($status, $fix, $prune);
|
||||
|
||||
if ($delay) {
|
||||
usleep($delay * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->report($fix);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function check(Status $status, bool $fix, bool $prune): void
|
||||
{
|
||||
$this->stats['checked']++;
|
||||
|
||||
$profile = $status->profile;
|
||||
$source = $status->object_url ?: $status->uri;
|
||||
|
||||
if (! $profile || $profile->domain === null || ! $source) {
|
||||
$this->stats['unreachable']++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$object = Helpers::fetchFromUrl($source);
|
||||
|
||||
if (is_array($object) && isset($object['object']) && is_array($object['object'])) {
|
||||
$object = $object['object'];
|
||||
}
|
||||
|
||||
// Gone, private, or not the object we stored. Nothing to learn.
|
||||
if (
|
||||
! is_array($object) ||
|
||||
($status->object_url && ($object['id'] ?? null) !== $status->object_url)
|
||||
) {
|
||||
$this->stats['unreachable']++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$inReplyTo = $object['inReplyTo'] ?? null;
|
||||
|
||||
if ($inReplyTo === null || $inReplyTo === '' || $inReplyTo === []) {
|
||||
$this->stats['top_level']++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->stats['orphans']++;
|
||||
|
||||
if (! $fix) {
|
||||
$url = Helpers::pluckval($inReplyTo);
|
||||
$known = is_string($url) && Helpers::findExistingStatus($url);
|
||||
|
||||
if ($known) {
|
||||
$this->stats['parent_known']++;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' orphan %s (%s) parent %s',
|
||||
$status->id,
|
||||
$status->type,
|
||||
$known ? 'is stored' : 'is not stored'
|
||||
), null, 'v');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolution = Helpers::resolveReplyParent($object, $profile);
|
||||
$parent = $resolution['status'];
|
||||
|
||||
if (
|
||||
$resolution['state'] === Helpers::REPLY_PARENT_RESOLVED &&
|
||||
$parent &&
|
||||
(string) $parent->id !== (string) $status->id
|
||||
) {
|
||||
$this->relink($status, $parent);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($prune) {
|
||||
RemoteStatusDelete::dispatch($status)->onQueue('delete');
|
||||
$this->stats['pruned']++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->stats['unrecoverable']++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the orphan to its parent and take it out of the places a
|
||||
* top-level post lives. No notification: the reply may be weeks old.
|
||||
*/
|
||||
private function relink(Status $status, Status $parent): void
|
||||
{
|
||||
$status->in_reply_to_id = $parent->id;
|
||||
$status->in_reply_to_profile_id = $parent->profile_id;
|
||||
$status->save();
|
||||
|
||||
$parent->reply_count = ($parent->reply_count ?? 0) + 1;
|
||||
$parent->save();
|
||||
|
||||
StatusService::del($status->id);
|
||||
StatusService::del($parent->id);
|
||||
Cache::forget('status:replies:all:'.$parent->id);
|
||||
|
||||
if (in_array($status->type, self::MEDIA_TYPES, true)) {
|
||||
NetworkTimelineService::del($status->id);
|
||||
FeedRemoveRemotePipeline::dispatch($status->id, $status->profile_id)->onQueue('feed');
|
||||
}
|
||||
|
||||
$this->stats['relinked']++;
|
||||
|
||||
$this->line(" relinked {$status->id} to {$parent->id}", null, 'v');
|
||||
}
|
||||
|
||||
private function report(bool $fix): void
|
||||
{
|
||||
$rows = [
|
||||
['Candidates checked', $this->stats['checked']],
|
||||
['Really top-level', $this->stats['top_level']],
|
||||
['Source unreachable, skipped', $this->stats['unreachable']],
|
||||
['Orphaned replies found', $this->stats['orphans']],
|
||||
];
|
||||
|
||||
if ($fix) {
|
||||
$rows[] = ['Relinked', $this->stats['relinked']];
|
||||
$rows[] = ['Deleted (--prune)', $this->stats['pruned']];
|
||||
$rows[] = ['Left alone, parent unavailable', $this->stats['unrecoverable']];
|
||||
} else {
|
||||
$rows[] = [' parent already stored', $this->stats['parent_known']];
|
||||
$rows[] = [' parent not stored', $this->stats['orphans'] - $this->stats['parent_known']];
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->table(['', 'Count'], $rows);
|
||||
|
||||
if (! $fix && $this->stats['orphans']) {
|
||||
$this->line('Run again with --fix to relink them. Add --prune to delete the ones that cannot be relinked.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\StatusPipeline;
|
||||
|
||||
use App\Models\Profile;
|
||||
use App\Util\ActivityPub\Helpers;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Holds a reply that arrived in an inbox while its parent could not be
|
||||
* resolved, and tries again later.
|
||||
*
|
||||
* Nothing is written for the reply until the parent resolves. A reply is
|
||||
* never stored without in_reply_to_id, because every timeline and profile
|
||||
* query reads "in_reply_to_id is null" as "top-level post". After the last
|
||||
* attempt the reply is dropped.
|
||||
*
|
||||
* Attempts are tracked on the job and re-dispatched with a delay instead of
|
||||
* using release()/tries, so an unreachable parent does not end up in
|
||||
* failed_jobs.
|
||||
*/
|
||||
class RemoteReplyResolvePipeline implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable;
|
||||
|
||||
/**
|
||||
* Delay in seconds before each attempt: 3m, 15m, 1h, 6h. The first value
|
||||
* must stay above Helpers::FETCH_NEGATIVE_TTL, otherwise the first retry
|
||||
* only reads back the cached failure.
|
||||
*/
|
||||
public const array BACKOFF = [180, 900, 3600, 21600];
|
||||
|
||||
/**
|
||||
* How long a reply is considered pending, a little over the sum of
|
||||
* BACKOFF. Stops a redelivered activity from starting a second chain.
|
||||
*/
|
||||
private const int PENDING_TTL = 28800;
|
||||
|
||||
public $timeout = 300;
|
||||
|
||||
public $tries = 1;
|
||||
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* @param array $object the delivered ActivityPub object (the Note)
|
||||
* @param int $profileId the remote author, already verified by the inbox
|
||||
* @param int $attempt index into BACKOFF of the attempt being run
|
||||
*/
|
||||
public function __construct(
|
||||
public array $object,
|
||||
public int $profileId,
|
||||
public int $attempt = 0
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Park a delivered reply and schedule the first attempt, once per object
|
||||
* id. (Not named queue(): the bus dispatcher treats a queue() method on a
|
||||
* job as a custom dispatch hook.)
|
||||
*/
|
||||
public static function park(array $object, Profile $profile): bool
|
||||
{
|
||||
$id = Helpers::pluckval($object['id'] ?? null);
|
||||
|
||||
if (! is_string($id) || $id === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! Cache::add(self::pendingKey($id), 1, self::PENDING_TTL)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::dispatch($object, (int) $profile->id, 0)
|
||||
->delay(now()->addSeconds(self::BACKOFF[0]))
|
||||
->onQueue('low');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function pendingKey(string $id): string
|
||||
{
|
||||
return 'pf:ap:reply-resolve:pending:'.hash('sha256', $id);
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$id = Helpers::pluckval($this->object['id'] ?? null);
|
||||
|
||||
if (! is_string($id) || ! Helpers::validateUrl($id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$profile = Profile::find($this->profileId);
|
||||
|
||||
// Author deleted, suspended or somehow local: nothing to store.
|
||||
if (! $profile || $profile->domain === null || $profile->status !== null) {
|
||||
$this->finish($id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Stored in the meantime by another path (Announce, Like, search).
|
||||
if (Helpers::findExistingStatus($id)) {
|
||||
$this->finish($id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolution = Helpers::resolveReplyParent($this->object, $profile);
|
||||
|
||||
if ($resolution['state'] === Helpers::REPLY_PARENT_UNRESOLVED) {
|
||||
$this->retryOrDrop($id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Helpers::replyParentAllowsStore($resolution)) {
|
||||
Helpers::storeStatus($id, $profile, $this->object);
|
||||
}
|
||||
|
||||
$this->finish($id);
|
||||
}
|
||||
|
||||
private function retryOrDrop(string $id): void
|
||||
{
|
||||
$next = $this->attempt + 1;
|
||||
|
||||
if (! isset(self::BACKOFF[$next])) {
|
||||
Log::info('RemoteReplyResolvePipeline: parent never resolved, dropping reply', [
|
||||
'id' => $id,
|
||||
'inReplyTo' => $this->object['inReplyTo'] ?? null,
|
||||
'attempts' => $next,
|
||||
]);
|
||||
|
||||
$this->finish($id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::dispatch($this->object, $this->profileId, $next)
|
||||
->delay(now()->addSeconds(self::BACKOFF[$next]))
|
||||
->onQueue('low');
|
||||
}
|
||||
|
||||
private function finish(string $id): void
|
||||
{
|
||||
Cache::forget(self::pendingKey($id));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Status;
|
||||
|
||||
use App\Jobs\StatusPipeline\RemoteStatusDelete;
|
||||
use App\Models\Status;
|
||||
|
||||
/**
|
||||
* What happens to the replies of a status that is being deleted.
|
||||
*
|
||||
* Both delete pipelines used to set in_reply_to_id to null on every reply.
|
||||
* Every timeline and profile query reads "in_reply_to_id is null" as
|
||||
* "top-level post", so each deleted status turned its replies into posts.
|
||||
*/
|
||||
class ReplyCleanupService
|
||||
{
|
||||
/**
|
||||
* Call right before the parent row is removed.
|
||||
*/
|
||||
public static function releaseRepliesOf(Status $parent): void
|
||||
{
|
||||
// Remote replies are cached copies of objects that still exist at
|
||||
// their origin. A reply without its parent has nowhere to render
|
||||
// here, so the copy goes too. in_reply_to_id is left untouched until
|
||||
// the job runs: a reply that briefly points at a missing parent stays
|
||||
// hidden, a reply set to null would leak into feeds. Each deletion
|
||||
// runs this again, so nested remote replies follow on their own.
|
||||
Status::whereInReplyToId($parent->id)
|
||||
->whereNotNull('uri')
|
||||
->chunkById(200, function ($replies) {
|
||||
foreach ($replies as $reply) {
|
||||
RemoteStatusDelete::dispatch($reply)->onQueue('delete');
|
||||
}
|
||||
});
|
||||
|
||||
// Local replies are a local user's content and are not ours to
|
||||
// remove because someone else deleted the post above them. They are
|
||||
// detached, as before. Feeds and profile grids only list media
|
||||
// types, so a text comment does not surface as a post. A reply that
|
||||
// carries media (possible through the Mastodon API) does end up on
|
||||
// its author's own profile, which is acceptable for their own media.
|
||||
Status::whereInReplyToId($parent->id)
|
||||
->whereNull('uri')
|
||||
->update(['in_reply_to_id' => null]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\HomeFeedPipeline\FeedRemoveRemotePipeline;
|
||||
use App\Jobs\StatusPipeline\RemoteStatusDelete;
|
||||
use App\Models\Profile;
|
||||
use App\Models\Status;
|
||||
use App\Models\User;
|
||||
use App\Util\ActivityPub\Helpers;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| fix:orphaned-replies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Remote replies stored as top-level statuses can only be recognised by
|
||||
| asking their origin. The command is read-only unless --fix is given.
|
||||
|
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
Redis::spy();
|
||||
Queue::fake();
|
||||
Http::fake();
|
||||
});
|
||||
|
||||
function orphanSeedHosts(): void
|
||||
{
|
||||
foreach (['remote.example', 'other.example', config('pixelfed.domain.app')] as $host) {
|
||||
Cache::put('helpers:url:public-ips:'.hash('xxh128', $host), ['203.0.113.40'], 3600);
|
||||
}
|
||||
|
||||
Cache::put('instances:banned:domains', [], 1209600);
|
||||
}
|
||||
|
||||
function orphanRemoteProfile(): Profile
|
||||
{
|
||||
return Profile::factory()->remote()->create([
|
||||
'domain' => 'remote.example',
|
||||
'username' => '@bob@remote.example',
|
||||
'remote_url' => 'https://remote.example/users/bob',
|
||||
'last_fetched_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote status stored as top-level, plus what its origin says it is.
|
||||
*/
|
||||
function orphanStatus(Profile $author, string $path, ?string $inReplyTo, string $type = 'text'): Status
|
||||
{
|
||||
$id = "https://remote.example/users/bob/statuses/{$path}";
|
||||
|
||||
$status = Status::factory()->create([
|
||||
'profile_id' => $author->id,
|
||||
'type' => $type,
|
||||
'uri' => "https://remote.example/@bob/{$path}",
|
||||
'url' => "https://remote.example/@bob/{$path}",
|
||||
'object_url' => $id,
|
||||
'local' => false,
|
||||
]);
|
||||
|
||||
Cache::put(Helpers::fetchCacheKey($id), [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'id' => $id,
|
||||
'type' => 'Note',
|
||||
'attributedTo' => $author->remote_url,
|
||||
'content' => '<p>hi</p>',
|
||||
'published' => now()->subHour()->toAtomString(),
|
||||
'inReplyTo' => $inReplyTo,
|
||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
], 600);
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
it('reports orphans without changing anything by default', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $user->profile_id]);
|
||||
$bob = orphanRemoteProfile();
|
||||
orphanSeedHosts();
|
||||
|
||||
$orphan = orphanStatus($bob, '1', $parent->url());
|
||||
$missing = orphanStatus($bob, '2', 'https://other.example/users/alice/statuses/unknown');
|
||||
|
||||
$this->artisan('fix:orphaned-replies')
|
||||
->expectsOutputToContain('Orphaned replies found')
|
||||
->assertSuccessful();
|
||||
|
||||
expect($orphan->fresh()->in_reply_to_id)->toBeNull();
|
||||
expect($missing->fresh()->in_reply_to_id)->toBeNull();
|
||||
expect(Status::count())->toBe(3);
|
||||
Http::assertNothingSent();
|
||||
Queue::assertNotPushed(RemoteStatusDelete::class);
|
||||
Queue::assertNotPushed(FeedRemoveRemotePipeline::class);
|
||||
});
|
||||
|
||||
it('relinks an orphan to its parent with --fix', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $user->profile_id, 'reply_count' => 2]);
|
||||
$bob = orphanRemoteProfile();
|
||||
orphanSeedHosts();
|
||||
|
||||
$orphan = orphanStatus($bob, '3', $parent->url());
|
||||
$topLevel = orphanStatus($bob, '4', null);
|
||||
|
||||
$this->artisan('fix:orphaned-replies', ['--fix' => true])->assertSuccessful();
|
||||
|
||||
$orphan->refresh();
|
||||
|
||||
expect((int) $orphan->in_reply_to_id)->toBe((int) $parent->id);
|
||||
expect((int) $orphan->in_reply_to_profile_id)->toBe((int) $user->profile_id);
|
||||
expect($parent->fresh()->reply_count)->toBe(3);
|
||||
expect($topLevel->fresh()->in_reply_to_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('pulls a relinked media orphan out of home feeds', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $user->profile_id]);
|
||||
$bob = orphanRemoteProfile();
|
||||
orphanSeedHosts();
|
||||
|
||||
$orphan = orphanStatus($bob, '5', $parent->url(), 'photo');
|
||||
|
||||
// Text only by default: a photo status is not a candidate.
|
||||
$this->artisan('fix:orphaned-replies', ['--fix' => true])->assertSuccessful();
|
||||
expect($orphan->fresh()->in_reply_to_id)->toBeNull();
|
||||
|
||||
$this->artisan('fix:orphaned-replies', ['--fix' => true, '--media' => true])->assertSuccessful();
|
||||
|
||||
expect((int) $orphan->fresh()->in_reply_to_id)->toBe((int) $parent->id);
|
||||
Queue::assertPushed(FeedRemoveRemotePipeline::class, 1);
|
||||
});
|
||||
|
||||
it('leaves unrecoverable orphans alone unless --prune is given', function () {
|
||||
$bob = orphanRemoteProfile();
|
||||
orphanSeedHosts();
|
||||
|
||||
$gone = 'https://other.example/users/alice/statuses/deleted';
|
||||
Cache::put(Helpers::fetchCacheKey($gone), false, 600);
|
||||
$orphan = orphanStatus($bob, '6', $gone);
|
||||
|
||||
$this->artisan('fix:orphaned-replies', ['--fix' => true])->assertSuccessful();
|
||||
|
||||
expect($orphan->fresh())->not->toBeNull();
|
||||
Queue::assertNotPushed(RemoteStatusDelete::class);
|
||||
|
||||
$this->artisan('fix:orphaned-replies', ['--fix' => true, '--prune' => true, '--no-interaction' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertPushedOn('delete', RemoteStatusDelete::class);
|
||||
});
|
||||
|
||||
it('skips a status whose origin no longer serves it', function () {
|
||||
$bob = orphanRemoteProfile();
|
||||
orphanSeedHosts();
|
||||
|
||||
$status = orphanStatus($bob, '7', 'https://other.example/users/alice/statuses/x');
|
||||
Cache::put(Helpers::fetchCacheKey($status->object_url), false, 600);
|
||||
|
||||
$this->artisan('fix:orphaned-replies', ['--fix' => true, '--prune' => true, '--no-interaction' => true])
|
||||
->assertSuccessful();
|
||||
|
||||
expect($status->fresh())->not->toBeNull();
|
||||
Queue::assertNotPushed(RemoteStatusDelete::class);
|
||||
});
|
||||
|
||||
it('refuses --prune without --fix', function () {
|
||||
$this->artisan('fix:orphaned-replies', ['--prune' => true])->assertFailed();
|
||||
});
|
||||
@ -0,0 +1,627 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\StatusPipeline\RemoteReplyResolvePipeline;
|
||||
use App\Jobs\StatusPipeline\StatusReplyPipeline;
|
||||
use App\Models\DirectMessage;
|
||||
use App\Models\Poll;
|
||||
use App\Models\Profile;
|
||||
use App\Models\Status;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDomainBlock;
|
||||
use App\Transformer\ActivityPub\Verb\CreateNote;
|
||||
use App\Transformer\ActivityPub\Verb\Note;
|
||||
use App\Util\ActivityPub\Helpers;
|
||||
use App\Util\ActivityPub\Inbox;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use League\Fractal;
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Remote reply threading
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Invariant: a remote object that declares inReplyTo is never stored without
|
||||
| in_reply_to_id. Every timeline and profile query reads "in_reply_to_id is
|
||||
| null" as "top-level post", so an unlinked reply breaks the thread and leaks
|
||||
| a comment into feeds.
|
||||
|
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
Redis::spy();
|
||||
Queue::fake();
|
||||
Http::fake();
|
||||
|
||||
config([
|
||||
'instance.enable_cc' => false,
|
||||
'federation.activitypub.enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
function replyLocalUser(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function replyRemoteProfile(string $domain = 'remote.example', string $username = 'bob'): 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 replySeedHosts(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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make $url dereference to $object, or to a failed fetch when $object is
|
||||
* false, by seeding the cache fetchFromUrl() reads.
|
||||
*/
|
||||
function replySeedFetch(string $url, array|false $object): void
|
||||
{
|
||||
Cache::put(Helpers::fetchCacheKey($url), $object, 600);
|
||||
}
|
||||
|
||||
function replyNote(Profile $author, string $path, ?string $inReplyTo, array $overrides = []): array
|
||||
{
|
||||
$id = $author->remote_url.'/statuses/'.$path;
|
||||
|
||||
return array_merge([
|
||||
'id' => $id,
|
||||
'type' => 'Note',
|
||||
'attributedTo' => $author->remote_url,
|
||||
'url' => "https://{$author->domain}/@user/{$path}",
|
||||
'content' => '<p>hello</p>',
|
||||
'published' => now()->subMinute()->toAtomString(),
|
||||
'inReplyTo' => $inReplyTo,
|
||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'cc' => [$author->remote_url.'/followers'],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function replyDeliver(Profile $actor, array $object, ?Profile $signer = null): void
|
||||
{
|
||||
$payload = [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'id' => $object['id'].'/activity',
|
||||
'type' => 'Create',
|
||||
'actor' => $actor->remote_url,
|
||||
'object' => $object,
|
||||
];
|
||||
|
||||
$headers = [
|
||||
'signature' => ['keyId="'.($signer ?? $actor)->key_id.'",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="dGVzdA=="'],
|
||||
'date' => [now()->toRfc7231String()],
|
||||
];
|
||||
|
||||
(new Inbox($headers, null, $payload))->handle();
|
||||
}
|
||||
|
||||
describe('storing', function () {
|
||||
it('links a delivered reply to a local parent and records the parent author', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
replyDeliver($bob, replyNote($bob, '1', $parent->url()));
|
||||
|
||||
$reply = Status::whereObjectUrl($bob->remote_url.'/statuses/1')->first();
|
||||
|
||||
expect($reply)->not->toBeNull();
|
||||
expect((int) $reply->in_reply_to_id)->toBe((int) $parent->id);
|
||||
expect((int) $reply->in_reply_to_profile_id)->toBe((int) $local->profile_id);
|
||||
|
||||
Queue::assertPushed(StatusReplyPipeline::class, 1);
|
||||
Queue::assertNotPushed(RemoteReplyResolvePipeline::class);
|
||||
});
|
||||
|
||||
it('stores the delivered object without fetching it back', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
// A followers-only reply: the origin would 404 our instance actor.
|
||||
$note = replyNote($bob, '2', $parent->url(), [
|
||||
'to' => [$bob->remote_url.'/followers'],
|
||||
'cc' => [$local->profile->permalink()],
|
||||
]);
|
||||
replySeedFetch($note['id'], false);
|
||||
replySeedFetch($note['url'], false);
|
||||
|
||||
replyDeliver($bob, $note);
|
||||
|
||||
$reply = Status::whereObjectUrl($note['id'])->first();
|
||||
|
||||
expect($reply)->not->toBeNull();
|
||||
expect($reply->scope)->toBe('private');
|
||||
expect((int) $reply->in_reply_to_id)->toBe((int) $parent->id);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('never stores a reply as a top-level status when the parent is unavailable', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$missing = 'https://other.example/users/alice/statuses/gone';
|
||||
replySeedFetch($missing, false);
|
||||
|
||||
$note = replyNote($bob, '3', $missing, [
|
||||
'attachment' => [[
|
||||
'type' => 'Document',
|
||||
'mediaType' => 'image/jpeg',
|
||||
'url' => 'https://remote.example/media/1.jpg',
|
||||
]],
|
||||
]);
|
||||
|
||||
expect(Helpers::storeStatus($note['id'], $bob, $note))->toBeNull();
|
||||
expect(Status::whereObjectUrl($note['id'])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('stores nothing from a chain that runs past the depth limit', function () {
|
||||
$alice = replyRemoteProfile('other.example', 'alice');
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
// c1 <- c2 <- ... <- c8, none of them known locally, c1 replies to
|
||||
// something that never resolves. The old code stored the ancestor at
|
||||
// the limit as a root with in_reply_to_id null.
|
||||
$previous = 'https://other.example/users/alice/statuses/c0';
|
||||
replySeedFetch($previous, false);
|
||||
|
||||
foreach (range(1, 8) as $i) {
|
||||
$note = replyNote($alice, "c{$i}", $previous);
|
||||
replySeedFetch($note['id'], ['@context' => 'https://www.w3.org/ns/activitystreams'] + $note);
|
||||
$previous = $note['id'];
|
||||
}
|
||||
|
||||
replyDeliver($bob, replyNote($bob, '4', $previous));
|
||||
|
||||
expect(Status::count())->toBe(0);
|
||||
Queue::assertPushed(RemoteReplyResolvePipeline::class, 1);
|
||||
});
|
||||
|
||||
it('fetches and links an unknown remote parent', function () {
|
||||
$alice = replyRemoteProfile('other.example', 'alice');
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$root = replyNote($alice, 'root', null);
|
||||
replySeedFetch($root['id'], ['@context' => 'https://www.w3.org/ns/activitystreams'] + $root);
|
||||
|
||||
replyDeliver($bob, replyNote($bob, '5', $root['id']));
|
||||
|
||||
$parent = Status::whereObjectUrl($root['id'])->first();
|
||||
$reply = Status::whereObjectUrl($bob->remote_url.'/statuses/5')->first();
|
||||
|
||||
expect($parent)->not->toBeNull();
|
||||
expect($parent->in_reply_to_id)->toBeNull();
|
||||
expect((int) $reply->in_reply_to_id)->toBe((int) $parent->id);
|
||||
expect((int) $reply->in_reply_to_profile_id)->toBe((int) $alice->id);
|
||||
});
|
||||
|
||||
it('does not count or notify twice when the same reply is stored again', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$note = replyNote($bob, '6', $parent->url());
|
||||
|
||||
$first = Helpers::storeStatus($note['id'], $bob, $note);
|
||||
$second = Helpers::storeStatus($note['id'], $bob, $note);
|
||||
|
||||
expect((int) $second->id)->toBe((int) $first->id);
|
||||
Queue::assertPushed(StatusReplyPipeline::class, 1);
|
||||
});
|
||||
|
||||
it('returns the existing row when it loses an insert race', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$note = replyNote($bob, '7', null);
|
||||
|
||||
// Same object_url under a different uri: updateOrCreate keys on uri,
|
||||
// misses, inserts, and hits the unique index on object_url. This is
|
||||
// what a second worker sees when it loses the race.
|
||||
$winner = Status::factory()->create([
|
||||
'profile_id' => $bob->id,
|
||||
'uri' => $note['id'],
|
||||
'url' => $note['id'],
|
||||
'object_url' => $note['id'],
|
||||
'local' => false,
|
||||
]);
|
||||
|
||||
$status = Helpers::createOrUpdateStatus(
|
||||
$note['url'], $bob, $note['id'], $note, $note['published'],
|
||||
null, false, 'public', false
|
||||
);
|
||||
|
||||
expect((int) $status->id)->toBe((int) $winner->id);
|
||||
expect(Status::whereObjectUrl($note['id'])->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusals', function () {
|
||||
it('drops a reply from a profile the parent author blocks', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
// Redis hands ids back as strings, model keys are integers.
|
||||
Redis::shouldReceive('zrevrange')->andReturn([(string) $bob->id]);
|
||||
|
||||
replyDeliver($bob, replyNote($bob, '8', $parent->url()));
|
||||
|
||||
expect(Status::whereObjectUrl($bob->remote_url.'/statuses/8')->exists())->toBeFalse();
|
||||
Queue::assertNotPushed(RemoteReplyResolvePipeline::class);
|
||||
});
|
||||
|
||||
it('drops a reply from a domain the parent author blocks', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
UserDomainBlock::create(['profile_id' => $local->profile_id, 'domain' => 'remote.example']);
|
||||
|
||||
replyDeliver($bob, replyNote($bob, '9', $parent->url()));
|
||||
|
||||
expect(Status::whereObjectUrl($bob->remote_url.'/statuses/9')->exists())->toBeFalse();
|
||||
Queue::assertNotPushed(RemoteReplyResolvePipeline::class);
|
||||
});
|
||||
|
||||
it('drops a reply to a local status with comments disabled', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create([
|
||||
'profile_id' => $local->profile_id,
|
||||
'comments_disabled' => true,
|
||||
]);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
replyDeliver($bob, replyNote($bob, '10', $parent->url()));
|
||||
|
||||
expect(Status::whereObjectUrl($bob->remote_url.'/statuses/10')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not self-fetch a local parent that no longer exists', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$gone = config('app.url').'/p/someone/999999999';
|
||||
|
||||
$result = Helpers::resolveReplyParent(replyNote($bob, '11', $gone), $bob);
|
||||
|
||||
expect($result['state'])->toBe(Helpers::REPLY_PARENT_UNRESOLVED);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('rejects a reply whose inReplyTo cannot be read as a URL', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$result = Helpers::resolveReplyParent(
|
||||
replyNote($bob, '12', null, ['inReplyTo' => ['type' => 'Note']]),
|
||||
$bob
|
||||
);
|
||||
|
||||
expect($result['state'])->toBe(Helpers::REPLY_PARENT_REJECTED);
|
||||
});
|
||||
|
||||
it('reads inReplyTo given as a list or an embedded object', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
foreach ([[$parent->url()], ['type' => 'Note', 'id' => $parent->url()]] as $shape) {
|
||||
$result = Helpers::resolveReplyParent(
|
||||
replyNote($bob, '13', null, ['inReplyTo' => $shape]),
|
||||
$bob
|
||||
);
|
||||
|
||||
expect($result['state'])->toBe(Helpers::REPLY_PARENT_RESOLVED);
|
||||
expect((int) $result['status']->id)->toBe((int) $parent->id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery trust', function () {
|
||||
it('does not store a delivered object attributed to someone other than the sender', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$bob = replyRemoteProfile();
|
||||
$mallory = replyRemoteProfile('remote.example', 'mallory');
|
||||
replySeedHosts();
|
||||
|
||||
$note = replyNote($bob, '14', $parent->url());
|
||||
replySeedFetch($note['id'], false);
|
||||
|
||||
replyDeliver($mallory, $note);
|
||||
|
||||
expect(Status::whereObjectUrl($note['id'])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not store a delivered object whose id is on another host', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$note = replyNote($bob, '15', $parent->url(), [
|
||||
'id' => 'https://other.example/users/alice/statuses/squat',
|
||||
'url' => 'https://other.example/@alice/squat',
|
||||
]);
|
||||
replySeedFetch($note['id'], false);
|
||||
|
||||
replyDeliver($bob, $note);
|
||||
|
||||
expect(Status::whereObjectUrl($note['id'])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('never stores a poll vote as a comment', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->create(['profile_id' => $local->profile_id, 'type' => 'poll']);
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$vote = replyNote($bob, '16', $parent->url(), ['name' => 'Option A']);
|
||||
unset($vote['content']);
|
||||
|
||||
replyDeliver($bob, $vote);
|
||||
|
||||
expect(Status::whereObjectUrl($vote['id'])->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create hardening', function () {
|
||||
it('counts a vote addressed only to the poll author instead of treating it as a dm', function () {
|
||||
$local = replyLocalUser();
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$status = Status::factory()->create(['profile_id' => $local->profile_id, 'type' => 'poll']);
|
||||
|
||||
$poll = new Poll;
|
||||
$poll->status_id = $status->id;
|
||||
$poll->profile_id = $local->profile_id;
|
||||
$poll->poll_options = ['Yes', 'No'];
|
||||
$poll->cached_tallies = [0, 0];
|
||||
$poll->votes_count = 0;
|
||||
$poll->expires_at = now()->addDay();
|
||||
$poll->save();
|
||||
|
||||
// Mastodon's vote shape: to the poll author alone, no cc, no content.
|
||||
$vote = replyNote($bob, 'vote', $status->url(), [
|
||||
'name' => 'No',
|
||||
'to' => [$local->profile->permalink()],
|
||||
'cc' => [],
|
||||
]);
|
||||
unset($vote['content']);
|
||||
|
||||
replyDeliver($bob, $vote);
|
||||
|
||||
expect($poll->fresh()->cached_tallies)->toBe([0, 1]);
|
||||
expect(DirectMessage::count())->toBe(0);
|
||||
expect(Status::whereObjectUrl($vote['id'])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not store a delivered post whose id is on another host', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
config(['federation.activitypub.ingest.store_notes_without_followers' => true]);
|
||||
|
||||
$note = replyNote($bob, 'squat', null, [
|
||||
'id' => 'https://other.example/users/alice/statuses/real',
|
||||
'url' => 'https://other.example/@alice/real',
|
||||
'attachment' => [[
|
||||
'type' => 'Document',
|
||||
'mediaType' => 'image/jpeg',
|
||||
'url' => 'https://remote.example/media/2.jpg',
|
||||
]],
|
||||
]);
|
||||
replySeedFetch($note['id'], false);
|
||||
|
||||
replyDeliver($bob, $note);
|
||||
|
||||
expect(Status::whereObjectUrl($note['id'])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('still stores a delivered post from its author', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
config(['federation.activitypub.ingest.store_notes_without_followers' => true]);
|
||||
|
||||
$note = replyNote($bob, 'photo', null, [
|
||||
'attachment' => [[
|
||||
'type' => 'Document',
|
||||
'mediaType' => 'image/jpeg',
|
||||
'url' => 'https://remote.example/media/3.jpg',
|
||||
]],
|
||||
]);
|
||||
|
||||
replyDeliver($bob, $note);
|
||||
|
||||
$status = Status::whereObjectUrl($note['id'])->first();
|
||||
|
||||
expect($status)->not->toBeNull();
|
||||
expect((int) $status->profile_id)->toBe((int) $bob->id);
|
||||
expect($status->in_reply_to_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('does not hand a status held by another profile to the loser of an insert race', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
$mallory = replyRemoteProfile('remote.example', 'mallory');
|
||||
replySeedHosts();
|
||||
|
||||
$note = replyNote($bob, 'held', null);
|
||||
|
||||
Status::factory()->create([
|
||||
'profile_id' => $mallory->id,
|
||||
'uri' => $note['id'],
|
||||
'url' => $note['id'],
|
||||
'object_url' => $note['id'],
|
||||
'local' => false,
|
||||
]);
|
||||
|
||||
$status = Helpers::createOrUpdateStatus(
|
||||
$note['url'], $bob, $note['id'], $note, $note['published'],
|
||||
null, false, 'public', false
|
||||
);
|
||||
|
||||
expect($status)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry', function () {
|
||||
it('parks a delivered reply once when the parent is unavailable', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$missing = 'https://other.example/users/alice/statuses/slow';
|
||||
replySeedFetch($missing, false);
|
||||
$note = replyNote($bob, '17', $missing);
|
||||
|
||||
replyDeliver($bob, $note);
|
||||
replyDeliver($bob, $note);
|
||||
|
||||
expect(Status::whereObjectUrl($note['id'])->exists())->toBeFalse();
|
||||
|
||||
Queue::assertPushed(RemoteReplyResolvePipeline::class, 1);
|
||||
Queue::assertPushed(
|
||||
RemoteReplyResolvePipeline::class,
|
||||
fn ($job) => $job->attempt === 0 && $job->queue === 'low' && $job->object['id'] === $note['id']
|
||||
);
|
||||
});
|
||||
|
||||
it('stores the parked reply once the parent resolves', function () {
|
||||
$alice = replyRemoteProfile('other.example', 'alice');
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$root = replyNote($alice, 'late', null);
|
||||
$note = replyNote($bob, '18', $root['id']);
|
||||
|
||||
replySeedFetch($root['id'], false);
|
||||
replyDeliver($bob, $note);
|
||||
expect(Status::count())->toBe(0);
|
||||
|
||||
replySeedFetch($root['id'], ['@context' => 'https://www.w3.org/ns/activitystreams'] + $root);
|
||||
(new RemoteReplyResolvePipeline($note, (int) $bob->id, 0))->handle();
|
||||
|
||||
$parent = Status::whereObjectUrl($root['id'])->first();
|
||||
$reply = Status::whereObjectUrl($note['id'])->first();
|
||||
|
||||
expect($reply)->not->toBeNull();
|
||||
expect((int) $reply->in_reply_to_id)->toBe((int) $parent->id);
|
||||
expect(Cache::has(RemoteReplyResolvePipeline::pendingKey($note['id'])))->toBeFalse();
|
||||
});
|
||||
|
||||
it('schedules the next attempt, then drops the reply after the last one', function () {
|
||||
$bob = replyRemoteProfile();
|
||||
replySeedHosts();
|
||||
|
||||
$missing = 'https://other.example/users/alice/statuses/never';
|
||||
replySeedFetch($missing, false);
|
||||
$note = replyNote($bob, '19', $missing);
|
||||
|
||||
(new RemoteReplyResolvePipeline($note, (int) $bob->id, 0))->handle();
|
||||
|
||||
Queue::assertPushed(RemoteReplyResolvePipeline::class, fn ($job) => $job->attempt === 1);
|
||||
|
||||
$last = count(RemoteReplyResolvePipeline::BACKOFF) - 1;
|
||||
(new RemoteReplyResolvePipeline($note, (int) $bob->id, $last))->handle();
|
||||
|
||||
Queue::assertPushed(RemoteReplyResolvePipeline::class, 1);
|
||||
expect(Status::whereObjectUrl($note['id'])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('waits longer than a failed fetch is cached before the first retry', function () {
|
||||
expect(RemoteReplyResolvePipeline::BACKOFF[0])->toBeGreaterThan(Helpers::FETCH_NEGATIVE_TTL);
|
||||
});
|
||||
|
||||
it('forgets a failed fetch well before it forgets a successful one', function () {
|
||||
replySeedHosts();
|
||||
$url = 'https://other.example/users/alice/statuses/flaky';
|
||||
|
||||
Http::fake(fn () => Http::response('', 503));
|
||||
expect(Helpers::fetchFromUrl($url))->toBeFalse();
|
||||
expect(Cache::get(Helpers::fetchCacheKey($url)))->toBeFalse();
|
||||
|
||||
$this->travel(Helpers::FETCH_NEGATIVE_TTL + 1)->seconds();
|
||||
|
||||
expect(Cache::get(Helpers::fetchCacheKey($url)))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('outbound inReplyTo', function () {
|
||||
it('references a remote parent by its ActivityPub id, not its permalink', function () {
|
||||
$local = replyLocalUser();
|
||||
$alice = replyRemoteProfile('other.example', 'alice');
|
||||
|
||||
$parent = Status::factory()->create([
|
||||
'profile_id' => $alice->id,
|
||||
'uri' => 'https://other.example/@alice/111',
|
||||
'url' => 'https://other.example/@alice/111',
|
||||
'object_url' => 'https://other.example/users/alice/statuses/111',
|
||||
'local' => false,
|
||||
]);
|
||||
|
||||
$reply = Status::factory()->create([
|
||||
'profile_id' => $local->profile_id,
|
||||
'in_reply_to_id' => $parent->id,
|
||||
'in_reply_to_profile_id' => $alice->id,
|
||||
]);
|
||||
|
||||
$fractal = new Fractal\Manager;
|
||||
$note = $fractal->createData(new Fractal\Resource\Item($reply, new Note))->toArray()['data'];
|
||||
$create = $fractal->createData(new Fractal\Resource\Item($reply, new CreateNote))->toArray()['data'];
|
||||
|
||||
expect($note['inReplyTo'])->toBe('https://other.example/users/alice/statuses/111');
|
||||
expect($create['object']['inReplyTo'])->toBe('https://other.example/users/alice/statuses/111');
|
||||
});
|
||||
|
||||
it('references a local parent by its status url', function () {
|
||||
$local = replyLocalUser();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $local->profile_id]);
|
||||
$reply = Status::factory()->create([
|
||||
'profile_id' => $local->profile_id,
|
||||
'in_reply_to_id' => $parent->id,
|
||||
]);
|
||||
|
||||
expect($reply->inReplyToUri())->toBe($parent->url());
|
||||
expect($parent->inReplyToUri())->toBeNull();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\StatusPipeline\RemoteStatusDelete;
|
||||
use App\Jobs\StatusPipeline\StatusDelete;
|
||||
use App\Models\Profile;
|
||||
use App\Models\Status;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
uses(LazilyRefreshDatabase::class);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Replies of a deleted status
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Both delete pipelines used to null in_reply_to_id on every reply, which
|
||||
| turned comments into top-level posts. Remote replies are now deleted with
|
||||
| the parent, local replies are detached.
|
||||
|
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
Redis::spy();
|
||||
Http::fake();
|
||||
|
||||
config(['federation.activitypub.enabled' => false]);
|
||||
});
|
||||
|
||||
function cleanupRemoteProfile(): Profile
|
||||
{
|
||||
return Profile::factory()->remote()->create([
|
||||
'domain' => 'remote.example',
|
||||
'remote_url' => 'https://remote.example/users/bob',
|
||||
]);
|
||||
}
|
||||
|
||||
function cleanupRemoteReply(Profile $author, Status $parent, string $path): Status
|
||||
{
|
||||
$id = "https://remote.example/users/bob/statuses/{$path}";
|
||||
|
||||
return Status::factory()->create([
|
||||
'profile_id' => $author->id,
|
||||
'in_reply_to_id' => $parent->id,
|
||||
'in_reply_to_profile_id' => $parent->profile_id,
|
||||
'uri' => $id,
|
||||
'url' => $id,
|
||||
'object_url' => $id,
|
||||
'local' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
it('queues remote replies for deletion when a local status is deleted', function () {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $user->profile_id]);
|
||||
$reply = cleanupRemoteReply(cleanupRemoteProfile(), $parent, '1');
|
||||
|
||||
(new StatusDelete($parent))->handle();
|
||||
|
||||
Queue::assertPushedOn('delete', RemoteStatusDelete::class);
|
||||
|
||||
// Still attached while it waits: hidden, never a top-level post.
|
||||
expect((int) $reply->fresh()->in_reply_to_id)->toBe((int) $parent->id);
|
||||
});
|
||||
|
||||
it('removes remote replies, and their remote replies, when the jobs run', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
$bob = cleanupRemoteProfile();
|
||||
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $user->profile_id]);
|
||||
$reply = cleanupRemoteReply($bob, $parent, '2');
|
||||
$nested = cleanupRemoteReply($bob, $reply, '3');
|
||||
|
||||
(new StatusDelete($parent))->handle();
|
||||
|
||||
expect(Status::withTrashed()->find($reply->id))->toBeNull();
|
||||
expect(Status::withTrashed()->find($nested->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('removes remote replies when a remote status is deleted', function () {
|
||||
$bob = cleanupRemoteProfile();
|
||||
|
||||
$parent = Status::factory()->photo()->create([
|
||||
'profile_id' => $bob->id,
|
||||
'uri' => 'https://remote.example/users/bob/statuses/root',
|
||||
'url' => 'https://remote.example/users/bob/statuses/root',
|
||||
'object_url' => 'https://remote.example/users/bob/statuses/root',
|
||||
'local' => false,
|
||||
]);
|
||||
$reply = cleanupRemoteReply($bob, $parent, '4');
|
||||
|
||||
(new RemoteStatusDelete($parent))->handle();
|
||||
|
||||
expect(Status::withTrashed()->find($parent->id))->toBeNull();
|
||||
expect(Status::withTrashed()->find($reply->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps and detaches local replies', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->refresh();
|
||||
$commenter = User::factory()->create();
|
||||
$commenter->refresh();
|
||||
|
||||
$parent = Status::factory()->photo()->create(['profile_id' => $user->profile_id]);
|
||||
$comment = Status::factory()->create([
|
||||
'profile_id' => $commenter->profile_id,
|
||||
'in_reply_to_id' => $parent->id,
|
||||
'in_reply_to_profile_id' => $user->profile_id,
|
||||
]);
|
||||
|
||||
(new StatusDelete($parent))->handle();
|
||||
|
||||
expect($comment->fresh())->not->toBeNull();
|
||||
expect($comment->fresh()->in_reply_to_id)->toBeNull();
|
||||
});
|
||||
Loading…
Reference in New Issue