Page MenuHomePhorge

D4803.1775161940.diff
No OneTemporary

Authored By
Unknown
Size
10 KB
Referenced Files
None
Subscribers
None

D4803.1775161940.diff

diff --git a/src/app/AuthAttempt.php b/src/app/AuthAttempt.php
--- a/src/app/AuthAttempt.php
+++ b/src/app/AuthAttempt.php
@@ -107,7 +107,9 @@
*/
public function notify(): bool
{
- return CompanionApp::notifyUser($this->user_id, ['token' => $this->id]);
+ // TODO: Standardize the message type/content
+ return \App\Backends\Events::pushMessage($this->user, ['auth' => $this->id]);
+ // return CompanionApp::notifyUser($this->user_id, ['token' => $this->id]);
}
/**
@@ -171,6 +173,16 @@
return $authAttempt;
}
+ /**
+ * The authenticating user.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class);
+ }
+
/**
* Trigger a notification if necessary and wait for confirmation.
*
diff --git a/src/app/Backends/Events.php b/src/app/Backends/Events.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/Events.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Backends;
+
+use App\User;
+use Illuminate\Support\Facades\Redis;
+
+class Events
+{
+ /**
+ * Pull message for the specified client application.
+ * It waits for the new message up to specified time limit.
+ *
+ * @param string $clientId Client identifier
+ * @param int $timeout Timeout in seconds
+ */
+ public static function pullMessage(string $clientId, int $timeout)
+ {
+ $element = Redis::brPop('push:' . $clientId, $timeout);
+
+ if (is_array($element) && is_string($element[1])) {
+ return json_decode($element[1]);
+ }
+
+ return null;
+ }
+
+ /**
+ * Push a notification to all clients of a user.
+ *
+ * @param User $user User object
+ * @param string|array $message Message/data to push
+ *
+ * @return bool True if message has been pushed to any client, False otherwise
+ */
+ public static function pushMessage(User $user, $message, $type = 'message'): bool
+ {
+ $count = 0;
+
+ $user->companionApps()
+ ->where('mfa_enabled', true)
+ ->each(function ($app) use ($message, $type, &$count) {
+ if ($app->isPaired()) {
+ $count++;
+ // FIXME: Should we maybe use oauth_client_id or notification_token?
+ self::pushMessageToClient($app->device_id, $message, $type);
+ }
+ });
+
+ return $count > 0;
+ }
+
+ /**
+ * Push a notification to specified client application
+ *
+ * @param string $clientId Client identifier
+ * @param string|array $message Message/data to push
+ * @param string $type Message type
+ * @param bool $prepend Prepend the message to the list (not append)
+ */
+ public static function pushMessageToClient(string $clientId, $message, $type = 'message', $prepend = false)
+ {
+ $data = json_encode([
+ 'type' => $type,
+ 'data' => $message,
+ ]);
+
+ $cache_key = 'push:' . $clientId;
+
+ if ($prepend) {
+ Redis::lPush($cache_key, $data);
+ } else {
+ Redis::rPush($cache_key, $data);
+ }
+
+ // TODO: How to make these entries to expire? It looks that we can set
+ // TTL on the whole list, but not it's elements. I.e. we might need
+ // to store expires_on value in the $data and skip them in pullMessage().
+ Redis::expire($cache_key, 10 * 60);
+ }
+}
diff --git a/src/app/Console/Development/PushClientCommand.php b/src/app/Console/Development/PushClientCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Development/PushClientCommand.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Console\Development;
+
+use App\Backends\Events;
+use App\Console\Command;
+
+class PushClientCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'push:client {client} {message} {--type=}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Push server-side event (notification) to a mobile device.';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $client = $this->argument('client');
+ $message = $this->argument('message');
+ $type = $this->option('type') ?: 'message';
+
+ Events::pushMessageToClient($client, $message, $type);
+ }
+}
diff --git a/src/app/Console/Development/PushUserCommand.php b/src/app/Console/Development/PushUserCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Development/PushUserCommand.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Console\Development;
+
+use App\Backends\Events;
+use App\Console\Command;
+
+class PushUserCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'push:user {user} {message} {--type=}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Push server-side event (notification) to user mobile device(s).';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $user = $this->getUser($this->argument('user'));
+
+ if (!$user) {
+ $this->error("User not found.");
+ return 1;
+ }
+
+ $message = $this->argument('message');
+ $type = $this->option('type') ?: 'message';
+
+ Events::pushMessage($user, $message, $type);
+ }
+}
diff --git a/src/app/Http/Controllers/SSEController.php b/src/app/Http/Controllers/SSEController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/SSEController.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Backends\Events;
+use Illuminate\Routing\Controller;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class SSEController extends Controller
+{
+ /**
+ * Time interval to wait for an event. It's a maximum time spent between
+ * ping events sent to the client to keep the connection alive.
+ *
+ * @const int
+ */
+ protected const KEEPALIVE = 10;
+
+ /** @var string Debug line prefix */
+ protected $debugPrefix = '';
+
+
+ /**
+ * Send notifications (Server-Side Events) to the client.
+ *
+ * @param string $clientId Client identifier
+ *
+ * @return \Illuminate\Http\Response|\Symfony\Component\HttpFoundation\StreamedResponse
+ */
+ public function notify($clientId)
+ {
+ // TODO: Validate that the client id exists and belongs to the authenticated user
+
+ // TODO: Per SSE spec. "a client can be told to stop reconnecting using the HTTP 204 No Content response code."
+ // should we send 204 if client is unknown/unauthorized?
+
+ // Octane has it's own max_execution_time limit. Which means when
+ // octane.max_execution_time is reached the connection will be
+ // dropped with "HTTP/1.1 408 Request Timeout" sent to the client.
+ // Clients are expected to re-connect when connection is dropped anyway.
+ // We could also send "please reconnect" event before we time out.
+ set_time_limit(0);
+
+ // Note: At the moment multiple connections from the same client are possible,
+ // but that means an event will be sent only to a single connection.
+ $cid = md5($clientId . microtime(true));
+ $start = time();
+
+ $this->debugPrefix = "PUSH [client={$clientId}, conn={$cid}]:";
+
+ \Log::debug("{$this->debugPrefix} Connection started");
+
+ $response = new StreamedResponse();
+
+ $response->headers->set('Content-Type', 'text/event-stream');
+ $response->headers->set('X-Accel-Buffering', 'no');
+ $response->headers->set('Cache-Control', 'no-cache,private');
+
+ $response->setCallback(function() use ($clientId, $start) {
+ // Note: This first output makes the headers to be sent too
+ $this->sendEvent('start', ['type' => 'start']);
+
+ while (true) {
+ // FIXME: connection_aborted() detects connection drop only after server
+ // sent something to the client, i.e. not immediately. Is there a better solution?
+ // We hope that at least the client can detect connection drops immediately
+ // and reconnect, otherwise potential to drop some notifications is quite big
+ if (connection_aborted()) {
+ \Log::debug("{$this->debugPrefix} Connection aborted");
+ return;
+ }
+
+ // Under Octane we exit before Octane does this for us. Octane does output
+ // "HTTP/1 408 Request Timeout" text which causes KTOR client to throw an exception.
+ $max = !empty($_SERVER['LARAVEL_OCTANE']) ? \config('octane.max_execution_time') : null;
+ if ($max && ($start + $max - self::KEEPALIVE < time())) {
+ return;
+ }
+
+ // Pull next message from the list (wait for it)
+ if ($element = Events::pullMessage($clientId, self::KEEPALIVE)) {
+ $this->sendEvent($element->type, $element->data);
+ } else {
+ $this->sendEvent('ping', ['type' => 'ping']);
+ }
+ }
+ });
+
+ return $response;
+ }
+
+ /**
+ * Outputs a Server Side Event
+ */
+ protected function sendEvent($event, $content): void
+ {
+ if (is_array($content)) {
+ $content = json_encode($content);
+ }
+
+ echo 'event: ' . $event . PHP_EOL
+ . 'data: ' . $content . PHP_EOL . PHP_EOL;
+
+ // If not under Octane we need to force output flush
+ if (empty($_SERVER['LARAVEL_OCTANE'])) {
+ ob_flush();
+ flush();
+ }
+
+ \Log::debug("{$this->debugPrefix} Sent $event");
+ }
+}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -204,6 +204,10 @@
function () {
Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']);
Route::post('meet', [API\V4\MeetController::class, 'webhook']);
+
+ // FIXME: This end-point should probably be authenticated with a passport client token
+ // and moved to /api/v4/ instead of /api/webhooks/. Then maybe {client} part is not needed?
+ Route::get('sse/{client}', [\App\Http\Controllers\SSEController::class, 'notify']);
}
);

File Metadata

Mime Type
text/plain
Expires
Thu, Apr 2, 8:32 PM (2 d, 2 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18820729
Default Alt Text
D4803.1775161940.diff (10 KB)

Event Timeline