diff --git a/app/Console/Commands/PushGatewayRefresh.php b/app/Console/Commands/PushGatewayRefresh.php new file mode 100644 index 000000000..79672695d --- /dev/null +++ b/app/Console/Commands/PushGatewayRefresh.php @@ -0,0 +1,72 @@ +info('Checking Push Notification support...'); + $this->line(' '); + + $currentState = NotificationAppGatewayService::enabled(); + + if ($currentState) { + $this->info('Push Notification support is active!'); + + return; + } else { + $this->error('Push notification support is NOT active'); + + $action = select( + label: 'Do you want to force re-check?', + options: ['Yes', 'No'], + required: true + ); + + if ($action === 'Yes') { + $recheck = NotificationAppGatewayService::forceSupportRecheck(); + if ($recheck) { + $this->info('Success! Push Notifications are now active!'); + + return; + } else { + $this->error('Error, please ensure you have a valid API key.'); + $this->line(' '); + $this->line('For more info, visit https://docs.pixelfed.org/running-pixelfed/push-notifications.html'); + $this->line(' '); + + return; + } + + return; + } else { + exit; + } + + return; + } + } +} diff --git a/app/Http/Controllers/Api/ApiV1Dot1Controller.php b/app/Http/Controllers/Api/ApiV1Dot1Controller.php index efd04c60d..3741390e2 100644 --- a/app/Http/Controllers/Api/ApiV1Dot1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Dot1Controller.php @@ -26,8 +26,10 @@ use App\Services\FollowerService; use App\Services\MediaBlocklistService; use App\Services\MediaPathService; use App\Services\NetworkTimelineService; +use App\Services\NotificationAppGatewayService; use App\Services\ProfileStatusService; use App\Services\PublicTimelineService; +use App\Services\PushNotificationService; use App\Services\StatusService; use App\Services\UserStorageService; use App\Status; @@ -54,8 +56,8 @@ class ApiV1Dot1Controller extends Controller public function __construct() { - $this->fractal = new Fractal\Manager(); - $this->fractal->setSerializer(new ArraySerializer()); + $this->fractal = new Fractal\Manager; + $this->fractal->setSerializer(new ArraySerializer); } public function json($res, $code = 200, $headers = []) @@ -317,7 +319,7 @@ class ApiV1Dot1Controller extends Controller if (config('pixelfed.bouncer.cloud_ips.ban_signups')) { abort_if(BouncerService::checkIp($request->ip()), 404); } - $agent = new Agent(); + $agent = new Agent; $currentIp = $request->ip(); $activity = AccountLog::whereUserId($user->id) @@ -575,7 +577,7 @@ class ApiV1Dot1Controller extends Controller $rtoken = Str::random(64); - $verify = new EmailVerification(); + $verify = new EmailVerification; $verify->user_id = $user->id; $verify->email = $user->email; $verify->user_token = $user->app_register_token; @@ -1019,14 +1021,20 @@ class ApiV1Dot1Controller extends Controller return $this->json($account, 200, $rateLimiting ? $limits : []); } - public function getExpoPushNotifications(Request $request) + public function getPushState(Request $request) { + abort_unless($request->hasHeader('X-PIXELFED-APP'), 404, 'Not found'); abort_if(! $request->user() || ! $request->user()->token(), 403); abort_unless($request->user()->tokenCan('push'), 403); - abort_unless(config('services.expo.access_token') && strlen(config('services.expo.access_token')) > 10, 404, 'Push notifications are not supported on this server.'); + abort_unless(NotificationAppGatewayService::enabled(), 404, 'Push notifications are not supported on this server.'); $user = $request->user(); + abort_if($user->status, 422, 'Cannot access this resource at this time'); $res = [ - 'expo_token' => (bool) $user->expo_token, + 'version' => PushNotificationService::PUSH_GATEWAY_VERSION, + 'username' => (string) $user->username, + 'profile_id' => (string) $user->profile_id, + 'notify_enabled' => (bool) $user->notify_enabled, + 'has_token' => (bool) $user->expo_token, 'notify_like' => (bool) $user->notify_like, 'notify_follow' => (bool) $user->notify_follow, 'notify_mention' => (bool) $user->notify_mention, @@ -1036,45 +1044,147 @@ class ApiV1Dot1Controller extends Controller return $this->json($res); } - public function disableExpoPushNotifications(Request $request) + public function disablePush(Request $request) { + abort_unless($request->hasHeader('X-PIXELFED-APP'), 404, 'Not found'); abort_if(! $request->user() || ! $request->user()->token(), 403); abort_unless($request->user()->tokenCan('push'), 403); - abort_unless(config('services.expo.access_token') && strlen(config('services.expo.access_token')) > 10, 404, 'Push notifications are not supported on this server.'); + abort_unless(NotificationAppGatewayService::enabled(), 404, 'Push notifications are not supported on this server.'); + abort_if($request->user()->status, 422, 'Cannot access this resource at this time'); + $request->user()->update([ + 'notify_enabled' => false, 'expo_token' => null, + 'notify_like' => false, + 'notify_follow' => false, + 'notify_mention' => false, + 'notify_comment' => false, ]); - return $this->json(['expo_token' => null]); + PushNotificationService::removeMemberFromAll($request->user()->profile_id); + + $user = $request->user(); + + return $this->json([ + 'version' => PushNotificationService::PUSH_GATEWAY_VERSION, + 'username' => (string) $user->username, + 'profile_id' => (string) $user->profile_id, + 'notify_enabled' => (bool) $user->notify_enabled, + 'has_token' => (bool) $user->expo_token, + 'notify_like' => (bool) $user->notify_like, + 'notify_follow' => (bool) $user->notify_follow, + 'notify_mention' => (bool) $user->notify_mention, + 'notify_comment' => (bool) $user->notify_comment, + ]); } - public function updateExpoPushNotifications(Request $request) + public function comparePush(Request $request) { + abort_unless($request->hasHeader('X-PIXELFED-APP'), 404, 'Not found'); abort_if(! $request->user() || ! $request->user()->token(), 403); abort_unless($request->user()->tokenCan('push'), 403); - abort_unless(config('services.expo.access_token') && strlen(config('services.expo.access_token')) > 10, 404, 'Push notifications are not supported on this server.'); + abort_unless(NotificationAppGatewayService::enabled(), 404, 'Push notifications are not supported on this server.'); + abort_if($request->user()->status, 422, 'Cannot access this resource at this time'); + $this->validate($request, [ 'expo_token' => ['required', ExpoPushToken::rule()], + ]); + + $user = $request->user(); + + if (empty($user->expo_token)) { + return $this->json([ + 'version' => PushNotificationService::PUSH_GATEWAY_VERSION, + 'username' => (string) $user->username, + 'profile_id' => (string) $user->profile_id, + 'notify_enabled' => (bool) $user->notify_enabled, + 'match' => false, + 'has_existing' => false, + ]); + } + + $token = $request->input('expo_token'); + $knownToken = $user->expo_token; + $match = hash_equals($knownToken, $token); + + return $this->json([ + 'version' => PushNotificationService::PUSH_GATEWAY_VERSION, + 'username' => (string) $user->username, + 'profile_id' => (string) $user->profile_id, + 'notify_enabled' => (bool) $user->notify_enabled, + 'match' => $match, + 'has_existing' => true, + ]); + } + + public function updatePush(Request $request) + { + abort_unless($request->hasHeader('X-PIXELFED-APP'), 404, 'Not found'); + abort_if(! $request->user() || ! $request->user()->token(), 403); + abort_unless($request->user()->tokenCan('push'), 403); + abort_unless(NotificationAppGatewayService::enabled(), 404, 'Push notifications are not supported on this server.'); + abort_if($request->user()->status, 422, 'Cannot access this resource at this time'); + + $this->validate($request, [ + 'notify_enabled' => 'required', + 'token' => ['required', ExpoPushToken::rule()], 'notify_like' => 'sometimes', 'notify_follow' => 'sometimes', 'notify_mention' => 'sometimes', 'notify_comment' => 'sometimes', ]); - $user = $request->user()->update([ - 'expo_token' => $request->input('expo_token'), - 'notify_like' => $request->has('notify_like') && $request->boolean('notify_like'), - 'notify_follow' => $request->has('notify_follow') && $request->boolean('notify_follow'), - 'notify_mention' => $request->has('notify_mention') && $request->boolean('notify_mention'), - 'notify_comment' => $request->has('notify_comment') && $request->boolean('notify_comment'), + $pid = $request->user()->profile_id; + abort_if(! $pid, 422, 'An error occured'); + $expoToken = $request->input('token'); + + $existing = User::where('profile_id', '!=', $pid)->whereExpoToken($expoToken)->count(); + abort_if($existing, 400, 'Push token is already used by another account'); + + $request->user()->update([ + 'notify_enabled' => $request->boolean('notify_enabled'), + 'expo_token' => $expoToken, ]); + if ($request->filled('notify_like')) { + $request->user()->update(['notify_like' => (bool) $request->boolean('notify_like')]); + $request->boolean('notify_like') == true ? + PushNotificationService::set('like', $pid) : + PushNotificationService::removeMember('like', $pid); + } + if ($request->filled('notify_follow')) { + $request->user()->update(['notify_follow' => (bool) $request->boolean('notify_follow')]); + $request->boolean('notify_follow') == true ? + PushNotificationService::set('follow', $pid) : + PushNotificationService::removeMember('follow', $pid); + } + if ($request->filled('notify_mention')) { + $request->user()->update(['notify_mention' => (bool) $request->boolean('notify_mention')]); + $request->boolean('notify_mention') == true ? + PushNotificationService::set('mention', $pid) : + PushNotificationService::removeMember('mention', $pid); + } + if ($request->filled('notify_comment')) { + $request->user()->update(['notify_comment' => (bool) $request->boolean('notify_comment')]); + $request->boolean('notify_comment') == true ? + PushNotificationService::set('comment', $pid) : + PushNotificationService::removeMember('comment', $pid); + } + + if ($request->boolean('notify_enabled') == false) { + PushNotificationService::removeMemberFromAll($request->user()->profile_id); + } + + $user = $request->user(); + $res = [ - 'expo_token' => (bool) $request->user()->expo_token, - 'notify_like' => (bool) $request->user()->notify_like, - 'notify_follow' => (bool) $request->user()->notify_follow, - 'notify_mention' => (bool) $request->user()->notify_mention, - 'notify_comment' => (bool) $request->user()->notify_comment, + 'version' => PushNotificationService::PUSH_GATEWAY_VERSION, + 'notify_enabled' => (bool) $user->notify_enabled, + 'has_token' => (bool) $user->expo_token, + 'notify_like' => (bool) $user->notify_like, + 'notify_follow' => (bool) $user->notify_follow, + 'notify_mention' => (bool) $user->notify_mention, + 'notify_comment' => (bool) $user->notify_comment, ]; return $this->json($res); @@ -1203,7 +1313,7 @@ class ApiV1Dot1Controller extends Controller abort(500, 'An error occured.'); } - $media = new Media(); + $media = new Media; $media->status_id = $status->id; $media->profile_id = $profile->id; $media->user_id = $user->id; @@ -1252,4 +1362,13 @@ class ApiV1Dot1Controller extends Controller return $this->json($res); } + + public function nagState(Request $request) + { + abort_unless((bool) config_cache('pixelfed.oauth_enabled'), 404); + + return [ + 'active' => NotificationAppGatewayService::enabled(), + ]; + } } diff --git a/app/Jobs/FollowPipeline/FollowPipeline.php b/app/Jobs/FollowPipeline/FollowPipeline.php index 67733919f..0df16206a 100644 --- a/app/Jobs/FollowPipeline/FollowPipeline.php +++ b/app/Jobs/FollowPipeline/FollowPipeline.php @@ -3,7 +3,13 @@ namespace App\Jobs\FollowPipeline; use App\Follower; +use App\Jobs\PushNotificationPipeline\FollowPushNotifyPipeline; use App\Notification; +use App\Services\AccountService; +use App\Services\FollowerService; +use App\Services\NotificationAppGatewayService; +use App\Services\PushNotificationService; +use App\User; use Cache; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -11,9 +17,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Log; -use Illuminate\Support\Facades\Redis; -use App\Services\AccountService; -use App\Services\FollowerService; class FollowPipeline implements ShouldQueue { @@ -49,16 +52,16 @@ class FollowPipeline implements ShouldQueue $actor = $follower->actor; $target = $follower->target; - if(!$actor || !$target) { + if (! $actor || ! $target) { return; } - if($target->domain || !$target->private_key) { + if ($target->domain || ! $target->private_key) { return; } - Cache::forget('profile:following:' . $actor->id); - Cache::forget('profile:following:' . $target->id); + Cache::forget('profile:following:'.$actor->id); + Cache::forget('profile:following:'.$target->id); FollowerService::add($actor->id, $target->id); @@ -72,9 +75,9 @@ class FollowPipeline implements ShouldQueue $target->save(); AccountService::del($target->id); - if($target->user_id && $target->domain === null) { + if ($target->user_id && $target->domain === null) { try { - $notification = new Notification(); + $notification = new Notification; $notification->profile_id = $target->id; $notification->actor_id = $actor->id; $notification->action = 'follow'; @@ -84,6 +87,15 @@ class FollowPipeline implements ShouldQueue } catch (Exception $e) { Log::error($e); } + + if (NotificationAppGatewayService::enabled()) { + if (PushNotificationService::check('follow', $target->id)) { + $user = User::whereProfileId($target->id)->first(); + if ($user && $user->expo_token && $user->notify_enabled) { + FollowPushNotifyPipeline::dispatch($user->expo_token, $actor->username)->onQueue('pushnotify'); + } + } + } } } } diff --git a/app/Jobs/LikePipeline/LikePipeline.php b/app/Jobs/LikePipeline/LikePipeline.php index e55c64f80..7dbd71da2 100644 --- a/app/Jobs/LikePipeline/LikePipeline.php +++ b/app/Jobs/LikePipeline/LikePipeline.php @@ -2,19 +2,22 @@ namespace App\Jobs\LikePipeline; -use Cache, DB, Log; -use Illuminate\Support\Facades\Redis; -use App\{Like, Notification}; +use App\Jobs\PushNotificationPipeline\LikePushNotifyPipeline; +use App\Like; +use App\Notification; +use App\Services\NotificationAppGatewayService; +use App\Services\PushNotificationService; +use App\Services\StatusService; +use App\Transformer\ActivityPub\Verb\Like as LikeTransformer; +use App\User; +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\SerializesModels; -use App\Util\ActivityPub\Helpers; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; -use App\Transformer\ActivityPub\Verb\Like as LikeTransformer; -use App\Services\StatusService; class LikePipeline implements ShouldQueue { @@ -30,6 +33,7 @@ class LikePipeline implements ShouldQueue public $deleteWhenMissingModels = true; public $timeout = 5; + public $tries = 1; /** @@ -54,34 +58,31 @@ class LikePipeline implements ShouldQueue $status = $this->like->status; $actor = $this->like->actor; - if (!$status) { + if (! $status) { // Ignore notifications to deleted statuses return; } - $status->likes_count = DB::table('likes')->whereStatusId($status->id)->count(); - $status->save(); - StatusService::refresh($status->id); - if($status->url && $actor->domain == null) { + 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(); + ->whereActorId($actor->id) + ->whereAction('like') + ->whereItemId($status->id) + ->whereItemType('App\Status') + ->count(); - if ($actor->id === $status->profile_id || $exists !== 0) { + if ($actor->id === $status->profile_id || $exists) { return true; } - if($status->uri === null && $status->object_url === null && $status->url === null) { + if ($status->uri === null && $status->object_url === null && $status->url === null) { try { - $notification = new Notification(); + $notification = new Notification; $notification->profile_id = $status->profile_id; $notification->actor_id = $actor->id; $notification->action = 'like'; @@ -91,6 +92,15 @@ class LikePipeline implements ShouldQueue } catch (Exception $e) { } + + 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); + } + } + } } } @@ -100,9 +110,9 @@ class LikePipeline implements ShouldQueue $status = $this->like->status; $actor = $this->like->actor; - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($like, new LikeTransformer()); + $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; diff --git a/app/Jobs/PushNotificationPipeline/FollowPushNotifyPipeline.php b/app/Jobs/PushNotificationPipeline/FollowPushNotifyPipeline.php new file mode 100644 index 000000000..ee286f5f2 --- /dev/null +++ b/app/Jobs/PushNotificationPipeline/FollowPushNotifyPipeline.php @@ -0,0 +1,38 @@ +pushToken = $pushToken; + $this->actor = $actor; + } + + /** + * Execute the job. + */ + public function handle(): void + { + try { + NotificationAppGatewayService::send($this->pushToken, 'follow', $this->actor); + } catch (Exception $e) { + return; + } + } +} diff --git a/app/Jobs/PushNotificationPipeline/LikePushNotifyPipeline.php b/app/Jobs/PushNotificationPipeline/LikePushNotifyPipeline.php new file mode 100644 index 000000000..892624b5a --- /dev/null +++ b/app/Jobs/PushNotificationPipeline/LikePushNotifyPipeline.php @@ -0,0 +1,38 @@ +pushToken = $pushToken; + $this->actor = $actor; + } + + /** + * Execute the job. + */ + public function handle(): void + { + try { + NotificationAppGatewayService::send($this->pushToken, 'like', $this->actor); + } catch (Exception $e) { + return; + } + } +} diff --git a/app/Jobs/PushNotificationPipeline/MentionPushNotifyPipeline.php b/app/Jobs/PushNotificationPipeline/MentionPushNotifyPipeline.php new file mode 100644 index 000000000..cad8c6fb5 --- /dev/null +++ b/app/Jobs/PushNotificationPipeline/MentionPushNotifyPipeline.php @@ -0,0 +1,38 @@ +pushToken = $pushToken; + $this->actor = $actor; + } + + /** + * Execute the job. + */ + public function handle(): void + { + try { + NotificationAppGatewayService::send($this->pushToken, 'mention', $this->actor); + } catch (Exception $e) { + return; + } + } +} diff --git a/app/Services/NotificationAppGatewayService.php b/app/Services/NotificationAppGatewayService.php new file mode 100644 index 000000000..c22d2c2e2 --- /dev/null +++ b/app/Services/NotificationAppGatewayService.php @@ -0,0 +1,125 @@ + 1]) + ->retry(3, 500) + ->throw() + ->get($endpoint); + + $data = $res->json(); + } catch (RequestException $e) { + return false; + } catch (Exception $e) { + return false; + } + + if ($res->successful() && isset($data['active']) && $data['active'] === true) { + return true; + } + + return false; + } + + public static function forceSupportRecheck() + { + Cache::forget(self::GATEWAY_SUPPORT_CHECK); + + return self::enabled(); + } + + public static function isValidExpoPushToken($token) + { + if (! $token || empty($token)) { + return false; + } + + if (str_starts_with($token, 'ExponentPushToken[') && mb_strlen($token) < 26) { + return false; + } + + if (! str_starts_with($token, 'ExponentPushToken[') && ! str_starts_with($token, 'ExpoPushToken[')) { + return false; + } + + if (! str_ends_with($token, ']')) { + return false; + } + + return true; + } + + public static function send($userToken, $type, $actor = '') + { + if (! self::enabled()) { + return false; + } + + if (! $userToken || empty($userToken) || ! self::isValidExpoPushToken($userToken)) { + return false; + } + + $types = PushNotificationService::NOTIFY_TYPES; + + if (! $type || empty($type) || ! in_array($type, $types)) { + return false; + } + + $apiKey = config('instance.notifications.nag.api_key'); + + if (! $apiKey || empty($apiKey)) { + return false; + } + $url = 'https://'.config('instance.notifications.nag.endpoint').'/api/v1/relay/deliver'; + + try { + $response = Http::withToken($apiKey) + ->withHeaders(['X-PIXELFED-API' => 1]) + ->post($url, [ + 'token' => $userToken, + 'type' => $type, + 'actor' => $actor, + ]); + + $response->throw(); + } catch (RequestException $e) { + return; + } catch (Exception $e) { + return; + } + } +} diff --git a/app/Services/PushNotificationService.php b/app/Services/PushNotificationService.php new file mode 100644 index 000000000..8acb07acc --- /dev/null +++ b/app/Services/PushNotificationService.php @@ -0,0 +1,123 @@ +first(); + if (! $user || $user->status || $user->deleted_at) { + return false; + } + + return Redis::sadd(self::ACTIVE_LIST_KEY.$listId, $memberId); + } + + public static function check($listId, $memberId) + { + return random_int(1, self::LOTTERY_ODDS) === 1 + ? self::isMemberDeepCheck($listId, $memberId) + : self::isMember($listId, $memberId); + } + + public static function isMember($listId, $memberId) + { + try { + return Redis::sismember(self::ACTIVE_LIST_KEY.$listId, $memberId); + } catch (Exception $e) { + return false; + } + } + + public static function isMemberDeepCheck($listId, $memberId) + { + $lock = Cache::lock(self::DEEP_CHECK_KEY.$listId, self::CACHE_LOCK_SECONDS); + + try { + $lock->block(5); + $actualCount = User::whereNull('status')->where('notify_enabled', true)->where('notify_'.$listId, true)->count(); + $cachedCount = self::count($listId); + if ($actualCount != $cachedCount) { + self::warmList($listId); + $user = User::where('notify_enabled', true)->where('profile_id', $memberId)->first(); + + return $user ? (bool) $user->{"notify_{$listId}"} : false; + } else { + return self::isMember($listId, $memberId); + } + } catch (Exception $e) { + Log::error('Failed during deep membership check: '.$e->getMessage()); + + return false; + } finally { + optional($lock)->release(); + } + } + + public static function removeMember($listId, $memberId) + { + return Redis::srem(self::ACTIVE_LIST_KEY.$listId, $memberId); + } + + public static function removeMemberFromAll($memberId) + { + foreach (self::NOTIFY_TYPES as $type) { + self::removeMember($type, $memberId); + } + + return 1; + } + + public static function count($listId) + { + if (! in_array($listId, self::NOTIFY_TYPES)) { + return false; + } + + return Redis::scard(self::ACTIVE_LIST_KEY.$listId); + } + + public static function warmList($listId) + { + if (! in_array($listId, self::NOTIFY_TYPES)) { + return false; + } + $key = self::ACTIVE_LIST_KEY.$listId; + Redis::del($key); + foreach (User::where('notify_'.$listId, true)->cursor() as $acct) { + if ($acct->status || $acct->deleted_at || ! $acct->profile_id || ! $acct->notify_enabled) { + continue; + } + Redis::sadd($key, $acct->profile_id); + } + + return self::count($listId); + } +} diff --git a/app/User.php b/app/User.php index 30b502308..086e4b0d8 100644 --- a/app/User.php +++ b/app/User.php @@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Passport\HasApiTokens; -use NotificationChannels\Expo\ExpoPushToken; use NotificationChannels\WebPush\HasPushSubscriptions; class User extends Authenticatable @@ -46,6 +45,7 @@ class User extends Authenticatable 'last_active_at', 'register_source', 'expo_token', + 'notify_enabled', 'notify_like', 'notify_follow', 'notify_mention', diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 6db3589f5..dd9b1578b 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -15,6 +15,7 @@ use App\Jobs\MovePipeline\MoveMigrateFollowersPipeline; use App\Jobs\MovePipeline\ProcessMovePipeline; use App\Jobs\MovePipeline\UnfollowLegacyAccountMovePipeline; use App\Jobs\ProfilePipeline\HandleUpdateActivity; +use App\Jobs\PushNotificationPipeline\MentionPushNotifyPipeline; use App\Jobs\StatusPipeline\RemoteStatusDelete; use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline; use App\Jobs\StoryPipeline\StoryExpire; @@ -27,7 +28,9 @@ use App\Notification; use App\Profile; use App\Services\AccountService; use App\Services\FollowerService; +use App\Services\NotificationAppGatewayService; use App\Services\PollService; +use App\Services\PushNotificationService; use App\Services\ReblogService; use App\Services\UserFilterService; use App\Status; @@ -242,7 +245,7 @@ class Inbox $cc = isset($activity['cc']) ? $activity['cc'] : []; if ($activity['type'] == 'Question') { - $this->handlePollCreate(); + //$this->handlePollCreate(); return; } @@ -531,6 +534,15 @@ class Inbox $notification->item_id = $dm->id; $notification->item_type = "App\DirectMessage"; $notification->save(); + + if (NotificationAppGatewayService::enabled()) { + if (PushNotificationService::check('mention', $profile->id)) { + $user = User::whereProfileId($profile->id)->first(); + if ($user && $user->expo_token && $user->notify_enabled) { + MentionPushNotifyPipeline::dispatch($user->expo_token, $actor->username)->onQueue('pushnotify'); + } + } + } } } diff --git a/config/horizon.php b/config/horizon.php index 0a4add6f8..5f7b31c13 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -93,6 +93,7 @@ return [ 'redis:adelete' => 30, 'redis:groups' => 30, 'redis:move' => 30, + 'redis:pushnotify' => 30, ], /* @@ -176,7 +177,7 @@ return [ 'production' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['high', 'default', 'follow', 'shared', 'inbox', 'feed', 'low', 'story', 'delete', 'mmo', 'intbg', 'groups', 'adelete', 'move'], + 'queue' => ['high', 'default', 'follow', 'shared', 'inbox', 'feed', 'low', 'story', 'delete', 'mmo', 'intbg', 'groups', 'adelete', 'move', 'pushnotify'], 'balance' => env('HORIZON_BALANCE_STRATEGY', 'auto'), 'minProcesses' => env('HORIZON_MIN_PROCESSES', 1), 'maxProcesses' => env('HORIZON_MAX_PROCESSES', 20), @@ -190,7 +191,7 @@ return [ 'local' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['high', 'default', 'follow', 'shared', 'inbox', 'feed', 'low', 'story', 'delete', 'mmo', 'intbg', 'groups', 'adelete', 'move'], + 'queue' => ['high', 'default', 'follow', 'shared', 'inbox', 'feed', 'low', 'story', 'delete', 'mmo', 'intbg', 'groups', 'adelete', 'move', 'pushnotify'], 'balance' => 'auto', 'minProcesses' => 1, 'maxProcesses' => 20, diff --git a/config/instance.php b/config/instance.php index c3912740f..3538fbf6d 100644 --- a/config/instance.php +++ b/config/instance.php @@ -151,6 +151,12 @@ return [ 'enabled' => env('INSTANCE_NOTIFY_AUTO_GC', false), 'delete_after_days' => env('INSTANCE_NOTIFY_AUTO_GC_DEL_AFTER_DAYS', 365), ], + + 'nag' => [ + 'enabled' => (bool) env('INSTANCE_NOTIFY_APP_GATEWAY', true), + 'api_key' => env('PIXELFED_PUSHGATEWAY_KEY', false), + 'endpoint' => 'push.pixelfed.net', + ], ], 'curated_registration' => [ @@ -171,6 +177,7 @@ return [ 'enabled' => env('INSTANCE_CUR_REG_NOTIFY_ADMIN_ON_VERIFY', false), 'bundle' => env('INSTANCE_CUR_REG_NOTIFY_ADMIN_ON_VERIFY_BUNDLE', false), 'max_per_day' => env('INSTANCE_CUR_REG_NOTIFY_ADMIN_ON_VERIFY_MPD', 10), + 'cc_addresses' => env('INSTANCE_CUR_REG_NOTIFY_ADMIN_ON_VERIFY_CC'), ], 'on_user_response' => env('INSTANCE_CUR_REG_NOTIFY_ADMIN_ON_USER_RESPONSE', false), ], diff --git a/database/migrations/2024_09_18_093322_add_notify_shares_to_users_table.php b/database/migrations/2024_09_18_093322_add_notify_shares_to_users_table.php new file mode 100644 index 000000000..54225d46a --- /dev/null +++ b/database/migrations/2024_09_18_093322_add_notify_shares_to_users_table.php @@ -0,0 +1,28 @@ +boolean('notify_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('notify_enabled'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 524e91d9b..f3d4b57bb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,10 +1,8 @@ middleware($middleware)->group(function() { +Route::prefix('api/v0/groups')->middleware($middleware)->group(function () { Route::get('config', 'Groups\GroupsApiController@getConfig'); Route::post('permission/create', 'Groups\CreateGroupsController@checkCreatePermission'); Route::post('create', 'Groups\CreateGroupsController@storeGroup'); @@ -87,9 +85,9 @@ Route::prefix('api/v0/groups')->middleware($middleware)->group(function() { Route::get('{id}', 'GroupController@getGroup'); }); -Route::group(['prefix' => 'api'], function() use($middleware) { +Route::group(['prefix' => 'api'], function () use ($middleware) { - Route::group(['prefix' => 'v1'], function() use($middleware) { + Route::group(['prefix' => 'v1'], function () use ($middleware) { Route::post('apps', 'Api\ApiV1Controller@apps'); Route::get('apps/verify_credentials', 'Api\ApiV1Controller@getApp')->middleware($middleware); Route::get('instance', 'Api\ApiV1Controller@instance'); @@ -170,7 +168,7 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('statuses/{id}/history', 'StatusEditController@history')->middleware($middleware); Route::put('statuses/{id}', 'StatusEditController@store')->middleware($middleware); - Route::group(['prefix' => 'admin'], function() use($middleware) { + Route::group(['prefix' => 'admin'], function () use ($middleware) { Route::get('domain_blocks', 'Api\V1\Admin\DomainBlocksController@index')->middleware($middleware); Route::post('domain_blocks', 'Api\V1\Admin\DomainBlocksController@create')->middleware($middleware); Route::get('domain_blocks/{id}', 'Api\V1\Admin\DomainBlocksController@show')->middleware($middleware); @@ -179,17 +177,17 @@ Route::group(['prefix' => 'api'], function() use($middleware) { })->middleware($middleware); }); - Route::group(['prefix' => 'v2'], function() use($middleware) { + Route::group(['prefix' => 'v2'], function () use ($middleware) { Route::get('search', 'Api\ApiV2Controller@search')->middleware($middleware); Route::post('media', 'Api\ApiV2Controller@mediaUploadV2')->middleware($middleware); Route::get('streaming/config', 'Api\ApiV2Controller@getWebsocketConfig'); Route::get('instance', 'Api\ApiV2Controller@instance'); }); - Route::group(['prefix' => 'v1.1'], function() use($middleware) { + Route::group(['prefix' => 'v1.1'], function () use ($middleware) { Route::post('report', 'Api\ApiV1Dot1Controller@report')->middleware($middleware); - Route::group(['prefix' => 'accounts'], function () use($middleware) { + Route::group(['prefix' => 'accounts'], function () use ($middleware) { Route::get('timelines/home', 'Api\ApiV1Controller@timelineHome')->middleware($middleware); Route::delete('avatar', 'Api\ApiV1Dot1Controller@deleteAvatar')->middleware($middleware); Route::get('{id}/posts', 'Api\ApiV1Dot1Controller@accountPosts')->middleware($middleware); @@ -202,7 +200,7 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('username/{username}', 'Api\ApiV1Dot1Controller@accountUsernameToId')->middleware($middleware); }); - Route::group(['prefix' => 'collections'], function () use($middleware) { + Route::group(['prefix' => 'collections'], function () use ($middleware) { Route::get('accounts/{id}', 'CollectionController@getUserCollections')->middleware($middleware); Route::get('items/{id}', 'CollectionController@getItems')->middleware($middleware); Route::get('view/{id}', 'CollectionController@getCollection')->middleware($middleware); @@ -213,7 +211,7 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('self', 'CollectionController@getSelfCollections')->middleware($middleware); }); - Route::group(['prefix' => 'direct'], function () use($middleware) { + Route::group(['prefix' => 'direct'], function () use ($middleware) { Route::get('thread', 'DirectMessageController@thread')->middleware($middleware); Route::post('thread/send', 'DirectMessageController@create')->middleware($middleware); Route::delete('thread/message', 'DirectMessageController@delete')->middleware($middleware); @@ -224,17 +222,17 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::post('lookup', 'DirectMessageController@composeLookup')->middleware($middleware); }); - Route::group(['prefix' => 'archive'], function () use($middleware) { + Route::group(['prefix' => 'archive'], function () use ($middleware) { Route::post('add/{id}', 'Api\ApiV1Dot1Controller@archive')->middleware($middleware); Route::post('remove/{id}', 'Api\ApiV1Dot1Controller@unarchive')->middleware($middleware); Route::get('list', 'Api\ApiV1Dot1Controller@archivedPosts')->middleware($middleware); }); - Route::group(['prefix' => 'places'], function () use($middleware) { + Route::group(['prefix' => 'places'], function () use ($middleware) { Route::get('posts/{id}/{slug}', 'Api\ApiV1Dot1Controller@placesById')->middleware($middleware); }); - Route::group(['prefix' => 'stories'], function () use($middleware) { + Route::group(['prefix' => 'stories'], function () use ($middleware) { Route::get('carousel', 'Stories\StoryApiV1Controller@carousel')->middleware($middleware); Route::post('add', 'Stories\StoryApiV1Controller@add')->middleware($middleware); Route::post('publish', 'Stories\StoryApiV1Controller@publish')->middleware($middleware); @@ -243,23 +241,23 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::post('comment', 'Stories\StoryApiV1Controller@comment')->middleware($middleware); }); - Route::group(['prefix' => 'compose'], function () use($middleware) { + Route::group(['prefix' => 'compose'], function () use ($middleware) { Route::get('search/location', 'ComposeController@searchLocation')->middleware($middleware); Route::get('settings', 'ComposeController@composeSettings')->middleware($middleware); }); - Route::group(['prefix' => 'discover'], function () use($middleware) { + Route::group(['prefix' => 'discover'], function () use ($middleware) { Route::get('accounts/popular', 'Api\ApiV1Controller@discoverAccountsPopular')->middleware($middleware); Route::get('posts/trending', 'DiscoverController@trendingApi')->middleware($middleware); Route::get('posts/hashtags', 'DiscoverController@trendingHashtags')->middleware($middleware); Route::get('posts/network/trending', 'DiscoverController@discoverNetworkTrending')->middleware($middleware); }); - Route::group(['prefix' => 'directory'], function () use($middleware) { + Route::group(['prefix' => 'directory'], function () { Route::get('listing', 'PixelfedDirectoryController@get'); }); - Route::group(['prefix' => 'auth'], function () use($middleware) { + Route::group(['prefix' => 'auth'], function () { Route::get('iarpfc', 'Api\ApiV1Dot1Controller@inAppRegistrationPreFlightCheck'); Route::post('iar', 'Api\ApiV1Dot1Controller@inAppRegistration'); Route::post('iarc', 'Api\ApiV1Dot1Controller@inAppRegistrationConfirm'); @@ -270,16 +268,18 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::post('invite/admin/ec', 'AdminInviteController@apiEmailCheck')->middleware('throttle:10,1440'); }); - Route::group(['prefix' => 'expo'], function() use($middleware) { - Route::get('push-notifications', 'Api\ApiV1Dot1Controller@getExpoPushNotifications')->middleware($middleware); - Route::post('push-notifications/update', 'Api\ApiV1Dot1Controller@updateExpoPushNotifications')->middleware($middleware); - Route::post('push-notifications/disable', 'Api\ApiV1Dot1Controller@disableExpoPushNotifications')->middleware($middleware); + Route::group(['prefix' => 'push'], function () use ($middleware) { + Route::get('state', 'Api\ApiV1Dot1Controller@getPushState')->middleware($middleware); + Route::post('compare', 'Api\ApiV1Dot1Controller@comparePush')->middleware($middleware); + Route::post('update', 'Api\ApiV1Dot1Controller@updatePush')->middleware($middleware); + Route::post('disable', 'Api\ApiV1Dot1Controller@disablePush')->middleware($middleware); }); Route::post('status/create', 'Api\ApiV1Dot1Controller@statusCreate')->middleware($middleware); + Route::get('nag/state', 'Api\ApiV1Dot1Controller@nagState'); }); - Route::group(['prefix' => 'live'], function() use($middleware) { + Route::group(['prefix' => 'live'], function () { // Route::post('create_stream', 'LiveStreamController@createStream')->middleware($middleware); // Route::post('stream/edit', 'LiveStreamController@editStream')->middleware($middleware); // Route::get('active/list', 'LiveStreamController@getActiveStreams')->middleware($middleware); @@ -297,7 +297,7 @@ Route::group(['prefix' => 'api'], function() use($middleware) { // Route::post('broadcast/finish', 'LiveStreamController@clientBroadcastFinish')->middleware($middleware); }); - Route::group(['prefix' => 'admin'], function() use($middleware) { + Route::group(['prefix' => 'admin'], function () use ($middleware) { Route::post('moderate/post/{id}', 'Api\ApiV1Dot1Controller@moderatePost')->middleware($middleware); Route::get('supported', 'Api\AdminApiController@supported')->middleware($middleware); Route::get('stats', 'Api\AdminApiController@getStats')->middleware($middleware); @@ -318,15 +318,15 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('instance/stats', 'Api\AdminApiController@getAllStats')->middleware($middleware); }); - Route::group(['prefix' => 'landing/v1'], function() use($middleware) { + Route::group(['prefix' => 'landing/v1'], function () { Route::get('directory', 'LandingController@getDirectoryApi'); }); - Route::group(['prefix' => 'pixelfed'], function() use($middleware) { - Route::group(['prefix' => 'v1'], function() use($middleware) { + Route::group(['prefix' => 'pixelfed'], function () use ($middleware) { + Route::group(['prefix' => 'v1'], function () use ($middleware) { Route::post('report', 'Api\ApiV1Dot1Controller@report')->middleware($middleware); - Route::group(['prefix' => 'accounts'], function () use($middleware) { + Route::group(['prefix' => 'accounts'], function () use ($middleware) { Route::get('timelines/home', 'Api\ApiV1Controller@timelineHome')->middleware($middleware); Route::delete('avatar', 'Api\ApiV1Dot1Controller@deleteAvatar')->middleware($middleware); Route::get('{id}/posts', 'Api\ApiV1Dot1Controller@accountPosts')->middleware($middleware); @@ -337,13 +337,13 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('apps-and-applications', 'Api\ApiV1Dot1Controller@accountApps')->middleware($middleware); }); - Route::group(['prefix' => 'archive'], function () use($middleware) { + Route::group(['prefix' => 'archive'], function () use ($middleware) { Route::post('add/{id}', 'Api\ApiV1Dot1Controller@archive')->middleware($middleware); Route::post('remove/{id}', 'Api\ApiV1Dot1Controller@unarchive')->middleware($middleware); Route::get('list', 'Api\ApiV1Dot1Controller@archivedPosts')->middleware($middleware); }); - Route::group(['prefix' => 'collections'], function () use($middleware) { + Route::group(['prefix' => 'collections'], function () use ($middleware) { Route::get('accounts/{id}', 'CollectionController@getUserCollections')->middleware($middleware); Route::get('items/{id}', 'CollectionController@getItems')->middleware($middleware); Route::get('view/{id}', 'CollectionController@getCollection')->middleware($middleware); @@ -354,12 +354,12 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('self', 'CollectionController@getSelfCollections')->middleware($middleware); }); - Route::group(['prefix' => 'compose'], function () use($middleware) { + Route::group(['prefix' => 'compose'], function () use ($middleware) { Route::get('search/location', 'ComposeController@searchLocation')->middleware($middleware); Route::get('settings', 'ComposeController@composeSettings')->middleware($middleware); }); - Route::group(['prefix' => 'direct'], function () use($middleware) { + Route::group(['prefix' => 'direct'], function () use ($middleware) { Route::get('thread', 'DirectMessageController@thread')->middleware($middleware); Route::post('thread/send', 'DirectMessageController@create')->middleware($middleware); Route::delete('thread/message', 'DirectMessageController@delete')->middleware($middleware); @@ -370,17 +370,17 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::post('lookup', 'DirectMessageController@composeLookup')->middleware($middleware); }); - Route::group(['prefix' => 'discover'], function () use($middleware) { + Route::group(['prefix' => 'discover'], function () use ($middleware) { Route::get('accounts/popular', 'Api\ApiV1Controller@discoverAccountsPopular')->middleware($middleware); Route::get('posts/trending', 'DiscoverController@trendingApi')->middleware($middleware); Route::get('posts/hashtags', 'DiscoverController@trendingHashtags')->middleware($middleware); }); - Route::group(['prefix' => 'directory'], function () use($middleware) { + Route::group(['prefix' => 'directory'], function () { Route::get('listing', 'PixelfedDirectoryController@get'); }); - Route::group(['prefix' => 'places'], function () use($middleware) { + Route::group(['prefix' => 'places'], function () use ($middleware) { Route::get('posts/{id}/{slug}', 'Api\ApiV1Dot1Controller@placesById')->middleware($middleware); }); @@ -389,7 +389,7 @@ Route::group(['prefix' => 'api'], function() use($middleware) { Route::get('app/settings', 'UserAppSettingsController@get')->middleware($middleware); Route::post('app/settings', 'UserAppSettingsController@store')->middleware($middleware); - Route::group(['prefix' => 'stories'], function () use($middleware) { + Route::group(['prefix' => 'stories'], function () use ($middleware) { Route::get('carousel', 'Stories\StoryApiV1Controller@carousel')->middleware($middleware); Route::get('self-carousel', 'Stories\StoryApiV1Controller@selfCarousel')->middleware($middleware); Route::post('add', 'Stories\StoryApiV1Controller@add')->middleware($middleware);