Merge pull request #5303 from pixelfed/staging

Add Notify App Gateway support
This commit is contained in:
daniel 2024-10-02 01:13:02 -06:00 committed by GitHub
commit e659798266
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 719 additions and 96 deletions

View file

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Services\NotificationAppGatewayService;
use Illuminate\Console\Command;
use function Laravel\Prompts\select;
class PushGatewayRefresh extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:push-gateway-refresh';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh push notification gateway support';
/**
* Execute the console command.
*/
public function handle()
{
$this->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;
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,38 @@
<?php
namespace App\Jobs\PushNotificationPipeline;
use App\Services\NotificationAppGatewayService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class FollowPushNotifyPipeline implements ShouldQueue
{
use Queueable;
public $pushToken;
public $actor;
/**
* Create a new job instance.
*/
public function __construct($pushToken, $actor)
{
$this->pushToken = $pushToken;
$this->actor = $actor;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
NotificationAppGatewayService::send($this->pushToken, 'follow', $this->actor);
} catch (Exception $e) {
return;
}
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Jobs\PushNotificationPipeline;
use App\Services\NotificationAppGatewayService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class LikePushNotifyPipeline implements ShouldQueue
{
use Queueable;
public $pushToken;
public $actor;
/**
* Create a new job instance.
*/
public function __construct($pushToken, $actor)
{
$this->pushToken = $pushToken;
$this->actor = $actor;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
NotificationAppGatewayService::send($this->pushToken, 'like', $this->actor);
} catch (Exception $e) {
return;
}
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Jobs\PushNotificationPipeline;
use App\Services\NotificationAppGatewayService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class MentionPushNotifyPipeline implements ShouldQueue
{
use Queueable;
public $pushToken;
public $actor;
/**
* Create a new job instance.
*/
public function __construct($pushToken, $actor)
{
$this->pushToken = $pushToken;
$this->actor = $actor;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
NotificationAppGatewayService::send($this->pushToken, 'mention', $this->actor);
} catch (Exception $e) {
return;
}
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace App\Services;
use Cache;
use Exception;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class NotificationAppGatewayService
{
const GATEWAY_SUPPORT_CHECK = 'px:nags:gateway-support-check';
public static function config()
{
return config('instance.notifications.nag');
}
public static function enabled()
{
if ((bool) config('instance.notifications.nag.enabled') === false) {
return false;
}
$apiKey = config('instance.notifications.nag.api_key');
if (! $apiKey || empty($apiKey) || strlen($apiKey) !== 45) {
return false;
}
return Cache::remember(self::GATEWAY_SUPPORT_CHECK, 43200, function () {
return self::checkServerSupport();
});
}
public static function checkServerSupport()
{
$endpoint = 'https://'.config('instance.notifications.nag.endpoint').'/api/v1/instance-check?domain='.config('pixelfed.domain.app');
try {
$res = Http::withHeaders(['X-PIXELFED-API' => 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;
}
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace App\Services;
use App\User;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
use Log;
class PushNotificationService
{
public const ACTIVE_LIST_KEY = 'pf:services:push-notify:active_deliver:';
public const NOTIFY_TYPES = ['follow', 'like', 'mention', 'comment'];
public const DEEP_CHECK_KEY = 'pf:services:push-notify:deep-check:';
public const PUSH_GATEWAY_VERSION = '1.0';
public const LOTTERY_ODDS = 20;
public const CACHE_LOCK_SECONDS = 10;
public static function get($list)
{
return Redis::smembers(self::ACTIVE_LIST_KEY.$list);
}
public static function set($listId, $memberId)
{
if (! in_array($listId, self::NOTIFY_TYPES)) {
return false;
}
$user = User::whereProfileId($memberId)->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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('notify_enabled')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('notify_enabled');
});
}
};

View file

@ -1,10 +1,8 @@
<?php
use Illuminate\Http\Request;
use App\Http\Middleware\DeprecatedEndpoint;
use App\Http\Controllers\Api\V1\TagsController;
$middleware = ['auth:api','validemail'];
$middleware = ['auth:api', 'validemail'];
Route::post('/f/inbox', 'FederationController@sharedInbox');
Route::post('/users/{username}/inbox', 'FederationController@userInbox');
@ -20,7 +18,7 @@ Route::redirect('.well-known/change-password', '/settings/password');
Route::get('api/nodeinfo/2.0.json', 'FederationController@nodeinfo');
Route::get('api/service/health-check', 'HealthCheckController@get');
Route::prefix('api/v0/groups')->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);