diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index a9bd5ba8f..3f81573f5 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -24,6 +24,7 @@ use App\Jobs\FollowPipeline\UnfollowPipeline; use App\Jobs\HomeFeedPipeline\FeedWarmCachePipeline; use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\LikePipeline\LikePipeline; +use App\Jobs\LikePipeline\UnlikePipeline; use App\Jobs\MediaPipeline\MediaDeletePipeline; use App\Jobs\MediaPipeline\MediaSyncLicensePipeline; use App\Jobs\NotificationPipeline\NotificationWarmUserCache; @@ -1382,17 +1383,16 @@ class ApiV1Controller extends Controller */ public function statusFavouriteById(Request $request, $id) { - abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_if(!$request->user() || !$request->user()->token(), 403); abort_unless($request->user()->tokenCan('write'), 403); $user = $request->user(); - abort_if($user->has_roles && ! UserRoleService::can('can-like', $user->id), 403, 'Invalid permissions for this action'); + abort_if($user->has_roles && !UserRoleService::can('can-like', $user->id), 403, 'Invalid permissions for this action'); $napi = $request->has(self::PF_API_ENTITY_KEY); $status = $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false); abort_unless($status, 404); - abort_if(isset($status['moved'], $status['moved']['id']), 422, 'Cannot like a post from an account that has migrated'); if ($status && isset($status['account'], $status['account']['acct']) && strpos($status['account']['acct'], '@') != -1) { @@ -1406,9 +1406,9 @@ class ApiV1Controller extends Controller if (intval($spid) !== intval($user->profile_id)) { if ($status['visibility'] == 'private') { - abort_if(! FollowerService::follows($user->profile_id, $spid), 403); + abort_if(!FollowerService::follows($user->profile_id, $spid), 403); } else { - abort_if(! in_array($status['visibility'], ['public', 'unlisted']), 403); + abort_if(!in_array($status['visibility'], ['public', 'unlisted']), 403); } } @@ -1424,23 +1424,47 @@ class ApiV1Controller extends Controller abort(422); } - $like = Like::firstOrCreate([ - 'profile_id' => $user->profile_id, - 'status_id' => $status['id'], - ]); + $like = DB::transaction(function () use ($user, $status, $spid, $id) { + $statusModel = Status::lockForUpdate()->find($status['id']); - if ($like->wasRecentlyCreated == true) { - $like->status_profile_id = $spid; - $like->is_comment = ! empty($status['in_reply_to_id']); - $like->save(); - Status::findOrFail($status['id'])->update([ - 'likes_count' => ($status['favourites_count'] ?? 0) + 1, - ]); - LikePipeline::dispatch($like)->onQueue('feed'); + if (!$statusModel) { + abort(404, 'Status not found'); + } + + $like = Like::firstOrCreate( + [ + 'profile_id' => $user->profile_id, + 'status_id' => $status['id'], + ], + [ + 'status_profile_id' => $spid, + 'is_comment' => !empty($status['in_reply_to_id']), + ] + ); + + if ($like->wasRecentlyCreated) { + $statusModel->increment('likes_count'); + + DB::afterCommit(function () use ($like) { + LikePipeline::dispatch($like)->onQueue('feed'); + }); + } + + return $like; + }); + + StatusService::del($status['id']); + $freshStatus = $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false); + + if ($freshStatus) { + $freshStatus['favourited'] = true; + $freshStatus['bookmarked'] = BookmarkService::get($user->profile_id, $status['id']); + $freshStatus['reblogged'] = ReblogService::get($user->profile_id, $status['id']); + return $this->json($freshStatus); } $status['favourited'] = true; - $status['favourites_count'] = $status['favourites_count'] + 1; + $status['favourites_count'] = ($status['favourites_count'] ?? 0) + ($like->wasRecentlyCreated ? 1 : 0); $status['bookmarked'] = BookmarkService::get($user->profile_id, $status['id']); $status['reblogged'] = ReblogService::get($user->profile_id, $status['id']); @@ -1455,11 +1479,11 @@ class ApiV1Controller extends Controller */ public function statusUnfavouriteById(Request $request, $id) { - abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_if(!$request->user() || !$request->user()->token(), 403); abort_unless($request->user()->tokenCan('write'), 403); $user = $request->user(); - abort_if($user->has_roles && ! UserRoleService::can('can-like', $user->id), 403, 'Invalid permissions for this action'); + abort_if($user->has_roles && !UserRoleService::can('can-like', $user->id), 403, 'Invalid permissions for this action'); $napi = $request->has(self::PF_API_ENTITY_KEY); $status = $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false); @@ -1478,29 +1502,42 @@ class ApiV1Controller extends Controller if (intval($spid) !== intval($user->profile_id)) { if ($status['visibility'] == 'private') { - abort_if(! FollowerService::follows($user->profile_id, $spid), 403); + abort_if(!FollowerService::follows($user->profile_id, $spid), 403); } else { - abort_if(! in_array($status['visibility'], ['public', 'unlisted']), 403); + abort_if(!in_array($status['visibility'], ['public', 'unlisted']), 403); } } - $like = Like::whereProfileId($user->profile_id) - ->whereStatusId($status['id']) - ->first(); + $didUnlike = DB::transaction(function () use ($user, $status) { + $like = Like::with(['actor', 'status']) + ->lockForUpdate() + ->whereProfileId($user->profile_id) + ->whereStatusId($status['id']) + ->first(); - if ($like) { - $like->forceDelete(); - $ogStatus = Status::find($status['id']); - if ($ogStatus) { - $ogStatus->likes_count = $ogStatus->likes_count > 1 ? $ogStatus->likes_count - 1 : 0; - $ogStatus->save(); + if (!$like) { + return false; } - } + + DB::afterCommit(function () use ($like) { + UnlikePipeline::dispatch($like)->onQueue('feed'); + }); + + return true; + }); StatusService::del($status['id']); + $freshStatus = $napi ? StatusService::get($id, false) : StatusService::getMastodon($id, false); + + if ($freshStatus) { + $freshStatus['favourited'] = false; + $freshStatus['bookmarked'] = BookmarkService::get($user->profile_id, $status['id']); + $freshStatus['reblogged'] = ReblogService::get($user->profile_id, $status['id']); + return $this->json($freshStatus); + } $status['favourited'] = false; - $status['favourites_count'] = isset($ogStatus) ? $ogStatus->likes_count : $status['favourites_count'] - 1; + $status['favourites_count'] = max(0, ($status['favourites_count'] ?? 0) - ($didUnlike ? 1 : 0)); $status['bookmarked'] = BookmarkService::get($user->profile_id, $status['id']); $status['reblogged'] = ReblogService::get($user->profile_id, $status['id']); diff --git a/app/Jobs/LikePipeline/LikePipeline.php b/app/Jobs/LikePipeline/LikePipeline.php index 48cb2f828..309d6749c 100644 --- a/app/Jobs/LikePipeline/LikePipeline.php +++ b/app/Jobs/LikePipeline/LikePipeline.php @@ -15,8 +15,9 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\DB; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; @@ -26,122 +27,101 @@ class LikePipeline implements ShouldQueue protected $like; - /** - * Delete the job if its models no longer exist. - * - * @var bool - */ public $deleteWhenMissingModels = true; - public $timeout = 5; + public $timeout = 30; - public $tries = 1; + public $tries = 3; + + public $maxExceptions = 2; + + public $backoff = [3, 10]; - /** - * Create a new job instance. - * - * @return void - */ public function __construct(Like $like) { $this->like = $like; } - /** - * Execute the job. - * - * @return void - */ + public function middleware() + { + return [ + (new WithoutOverlapping("like:{$this->like->status_id}:{$this->like->profile_id}")) + ->releaseAfter(10) + ->expireAfter(60), + ]; + } + + public function uniqueId() + { + return "like:{$this->like->status_id}:{$this->like->profile_id}"; + } + public function handle() { $like = $this->like; + $status = $like->status; + $actor = $like->actor; - // Check if like still exists (in case it was soft deleted or removed) - if (!$like) { - Log::info("LikePipeline: Like no longer exists, skipping job"); + if (! $status) { return; } - $status = $this->like->status; - $actor = $this->like->actor; + if ($status->url && $actor->domain == null) { + $this->remoteLikeDeliver(); + StatusService::refresh($status->id); - // Verify both status and actor exist - if (!$status) { - Log::info("LikePipeline: Status no longer exists for like {$like->id}, skipping job"); - return; - } - if (!$actor) { - Log::info("LikePipeline: Actor no longer exists for like {$like->id}, skipping job"); return; } - StatusService::refresh($status->id); + if ($actor->id === $status->profile_id) { + StatusService::refresh($status->id); - if ($status->url && $actor->domain == null) { - return $this->remoteLikeDeliver(); - } - - $exists = Notification::whereProfileId($status->profile_id) - ->whereActorId($actor->id) - ->whereAction('like') - ->whereItemId($status->id) - ->whereItemType('App\Status') - ->count(); - - if ($actor->id === $status->profile_id || $exists) { - return true; + return; } if ($status->uri === null && $status->object_url === null && $status->url === null) { - try { - $notification = new Notification; - $notification->profile_id = $status->profile_id; - $notification->actor_id = $actor->id; - $notification->action = 'like'; - $notification->item_id = $status->id; - $notification->item_type = "App\Status"; - $notification->save(); - - } catch (\Exception $e) { - Log::warning("LikePipeline: Failed to create notification for like {$like->id}: " . $e->getMessage()); - } - - if (NotificationAppGatewayService::enabled()) { - if (PushNotificationService::check('like', $status->profile_id)) { - $user = User::whereProfileId($status->profile_id)->first(); - if ($user && $user->expo_token && $user->notify_enabled) { - LikePushNotifyPipeline::dispatchSync($user->expo_token, $actor->username); - } + DB::transaction(function () use ($status, $actor) { + $notification = Notification::firstOrCreate( + [ + 'profile_id' => $status->profile_id, + 'actor_id' => $actor->id, + 'action' => 'like', + 'item_id' => $status->id, + 'item_type' => 'App\Status', + ] + ); + + if ($notification->wasRecentlyCreated) { + $this->sendPushNotification($status, $actor); } - } + }); } + + StatusService::refresh($status->id); } - public function remoteLikeDeliver() + protected function sendPushNotification($status, $actor) { - $like = $this->like; - $status = $this->like->status; - $actor = $this->like->actor; - - // Verify all required models exist before attempting delivery - if (!$like) { - Log::info("LikePipeline: Like missing for remote delivery, skipping"); + if (! NotificationAppGatewayService::enabled()) { return; } - if (!$status) { - Log::info("LikePipeline: status missing for remote delivery, skipping"); - return; - } - if (!$actor) { - Log::info("LikePipeline: actor missing for remote delivery, skipping"); + + if (! PushNotificationService::check('like', $status->profile_id)) { return; } - // Verify status has a profile - if (!$status->profile) { - Log::info("LikePipeline: Status profile missing for like {$like->id}, skipping remote delivery"); - return; + $user = User::whereProfileId($status->profile_id)->first(); + + if ($user && $user->expo_token && $user->notify_enabled) { + LikePushNotifyPipeline::dispatchSync($user->expo_token, $actor->username); } + } + + public function remoteLikeDeliver() + { + $like = $this->like; + $status = $like->status; + $actor = $like->actor; $fractal = new Fractal\Manager; $fractal->setSerializer(new ArraySerializer); @@ -150,11 +130,6 @@ class LikePipeline implements ShouldQueue $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; - if (!$url) { - Log::info("LikePipeline: No inbox URL available for like {$like->id}, skipping remote delivery"); - return; - } - Helpers::sendSignedObject($actor, $url, $activity); } } diff --git a/app/Jobs/LikePipeline/UnlikePipeline.php b/app/Jobs/LikePipeline/UnlikePipeline.php index 5345ea82e..eea319ad5 100644 --- a/app/Jobs/LikePipeline/UnlikePipeline.php +++ b/app/Jobs/LikePipeline/UnlikePipeline.php @@ -2,143 +2,105 @@ namespace App\Jobs\LikePipeline; -use Cache, DB, Log; -use Illuminate\Support\Facades\Redis; -use App\{Like, Notification}; +use App\Like; +use App\Notification; +use App\Services\StatusService; +use App\Transformer\ActivityPub\Verb\UndoLike as LikeTransformer; +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\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; -use App\Util\ActivityPub\Helpers; +use Illuminate\Support\Facades\DB; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; -use App\Transformer\ActivityPub\Verb\UndoLike as LikeTransformer; -use App\Services\StatusService; class UnlikePipeline implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - - protected $like; - - /** - * Delete the job if its models no longer exist. - * - * @var bool - */ - public $deleteWhenMissingModels = true; - - public $timeout = 5; - public $tries = 1; - - /** - * Create a new job instance. - * - * @return void - */ - public function __construct(Like $like) - { - $this->like = $like; - } - - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - $like = $this->like; - - // Check if like still exists (in case it was already deleted) - if (!$like) { - Log::info("UnlikePipeline: Like no longer exists, skipping job"); - return; - } - - $status = $this->like->status; - $actor = $this->like->actor; - - // Verify both status and actor exist - if (!$status) { - Log::info("UnlikePipeline: Status no longer exists for like {$like->id}, skipping job"); - return; - } - if (!$actor) { - Log::info("UnlikePipeline: Actor no longer exists for like {$like->id}, skipping job"); - return; - } - - $status->likes_count = DB::table('likes')->whereStatusId($status->id)->count(); - $status->save(); - - StatusService::refresh($status->id); - - if($actor->id !== $status->profile_id && $status->url && $actor->domain == null) { - $this->remoteLikeDeliver(); - } - - $exists = Notification::whereProfileId($status->profile_id) - ->whereActorId($actor->id) - ->whereAction('like') - ->whereItemId($status->id) - ->whereItemType('App\Status') - ->first(); - - if($exists) { - $exists->delete(); - } - - $like = Like::whereProfileId($actor->id)->whereStatusId($status->id)->first(); - - if(!$like) { - return; - } - - $like->forceDelete(); - - return; - } - - public function remoteLikeDeliver() - { - $like = $this->like; - $status = $this->like->status; - $actor = $this->like->actor; - - // Verify all required models exist before attempting delivery - if (!$like) { - Log::info("UnlikePipeline: Like missing for remote delivery, skipping"); - return; - } - if (!$status) { - Log::info("UnlikePipeline: Status missing for remote delivery, skipping"); - return; - } - if (!$actor) { - Log::info("UnlikePipeline: Actor missing for remote delivery, skipping"); - return; - } - - // Verify status has a profile - if (!$status->profile) { - Log::info("UnlikePipeline: Status profile missing for like {$like->id}, skipping remote delivery"); - return; - } - - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($like, new LikeTransformer()); - $activity = $fractal->createData($resource)->toArray(); - - $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; - - if (!$url) { - Log::info("UnlikePipeline: No inbox URL available for like {$like->id}, skipping remote delivery"); - return; - } - - Helpers::sendSignedObject($actor, $url, $activity); - } + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + protected $like; + + public $deleteWhenMissingModels = true; + + public $timeout = 30; + + public $tries = 3; + + public $maxExceptions = 2; + + public $backoff = [3, 10]; + + public function __construct(Like $like) + { + $this->like = $like; + } + + public function middleware() + { + return [ + (new WithoutOverlapping("unlike:{$this->like->status_id}:{$this->like->profile_id}")) + ->releaseAfter(10) + ->expireAfter(60), + ]; + } + + public function uniqueId() + { + return "unlike:{$this->like->status_id}:{$this->like->profile_id}"; + } + + public function handle() + { + $like = $this->like; + $status = $like->status; + $actor = $like->actor; + + if (! $status) { + return; + } + + DB::transaction(function () use ($status, $actor, $like) { + if ($status->likes_count > 0) { + $status->decrement('likes_count'); + } + + Notification::whereProfileId($status->profile_id) + ->whereActorId($actor->id) + ->whereAction('like') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->chunkById(100, function ($notifications) { + foreach ($notifications as $notification) { + $notification->forceDelete(); + } + }); + + $like->forceDelete(); + }); + + if ($actor->id !== $status->profile_id && $status->url && $actor->domain == null) { + $this->remoteLikeDeliver(); + } + + StatusService::refresh($status->id); + } + + public function remoteLikeDeliver() + { + $like = $this->like; + $status = $like->status; + $actor = $like->actor; + + $fractal = new Fractal\Manager; + $fractal->setSerializer(new ArraySerializer); + $resource = new Fractal\Resource\Item($like, new LikeTransformer); + $activity = $fractal->createData($resource)->toArray(); + + $url = $status->profile->sharedInbox ?? $status->profile->inbox_url; + + Helpers::sendSignedObject($actor, $url, $activity); + } } diff --git a/app/Like.php b/app/Like.php index 0c2c7f363..05c1836ce 100644 --- a/app/Like.php +++ b/app/Like.php @@ -17,7 +17,7 @@ class Like extends Model * @var array */ protected $casts = [ - 'deleted_at' => 'datetime' + 'deleted_at' => 'datetime', ]; protected $fillable = ['profile_id', 'status_id', 'status_profile_id']; diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 944641db6..3f26a2d79 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -775,6 +775,13 @@ class Inbox if (! $profile || $profile->private_key != null) { return; } + + Notification::whereActorId($profile->id) + ->chunkById(100, function ($notifications) { + foreach ($notifications as $notification) { + $notification->forceDelete(); + } + }); DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('inbox'); return; @@ -795,6 +802,14 @@ class Inbox if ($status->profile_id != $profile->id) { return; } + $notifications = Notification::whereActorId($status->profile_id) + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->get(); + foreach ($notifications as $notification) { + $notification->forceDelete(); + } + if ($status->scope && in_array($status->scope, ['public', 'unlisted', 'private'])) { if ($status->type && ! in_array($status->type, ['story:reaction', 'story:reply', 'reply'])) { FeedRemoveRemotePipeline::dispatch($status->id, $status->profile_id)->onQueue('feed'); @@ -916,12 +931,17 @@ class Inbox FeedRemoveRemotePipeline::dispatch($status->id, $status->profile_id)->onQueue('feed'); Status::whereProfileId($profile->id) ->whereReblogOfId($status->id) - ->delete(); + ->forceDelete(); + + if ($status->reblogs_count) { + $status->reblogs_count = $status->reblogs_count - 1; + $status->saveQuietly(); + } ReblogService::removePostReblog($profile->id, $status->id); $notifications = Notification::whereProfileId($status->profile_id) ->whereActorId($profile->id) ->whereAction('share') - ->whereItemId($status->reblog_of_id) + ->whereItemId($status->id) ->whereItemType('App\Status') ->get(); foreach ($notifications as $notification) {