Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117740941
D4803.1775161940.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
10 KB
Referenced Files
None
Subscribers
None
D4803.1775161940.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D4803: WIP: Server Side Events
Attached
Detach File
Event Timeline