Files
rspade_system/app/RSpade/Core/Realtime/Realtime.php

156 lines
4.6 KiB
PHP
Executable File

<?php
namespace App\RSpade\Core\Realtime;
use Illuminate\Support\Facades\Redis;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Realtime\Realtime_Topic_Abstract;
use App\RSpade\Core\Session\Session;
/**
* Realtime
*
* Static API for the WebSocket realtime notification system.
*
* PHP is the authority — it issues auth tokens, checks permissions,
* and publishes events. The Node.js WebSocket server is a dumb relay
* that validates HMAC signatures and routes messages.
*
* Usage:
* // Publish after saving a record
* Realtime::publish('Contact_Updated_Topic', ['id' => $contact->id]);
*
* // Generate tokens (called by Realtime_Controller)
* $token = Realtime::connection_token();
* $token = Realtime::subscribe_token('Contact_Updated_Topic', ['id' => 5]);
*/
class Realtime
{
/**
* Token expiry in seconds
*/
private const TOKEN_EXPIRY = 60;
/**
* Redis channel prefix for realtime messages
*/
private const REDIS_PREFIX = 'rsx_rt';
/**
* Check if realtime is enabled
*/
public static function is_enabled(): bool
{
return env('REALTIME_ENABLED', false);
}
/**
* Generate a connection token for WebSocket authentication
*
* Contains user_id, site_id, session_id, and expiry.
* Signed with APP_KEY via HMAC-SHA256.
* Short-lived (60 seconds) — only valid for initial handshake.
*
* @return string Signed token
*/
public static function connection_token(): string
{
$payload = [
'user_id' => Session::get_user_id(),
'site_id' => Session::get_site_id(),
'session_id' => Session::get_session_id(),
'exp' => time() + self::TOKEN_EXPIRY,
];
return self::_sign_token($payload);
}
/**
* Generate a subscribe token for a specific topic
*
* Checks the topic's can_subscribe() method first.
* Contains topic name, filter, site_id, and expiry.
*
* @param string $topic_class Topic class name (e.g., 'Contact_Updated_Topic')
* @param array $filter Subscription filter (e.g., ['id' => 5])
* @return string Signed token
* @throws \RuntimeException If topic class not found or permission denied
*/
public static function subscribe_token(string $topic_class, array $filter = []): string
{
// Resolve topic class
$fqcn = Manifest::resolve_class($topic_class);
if (!$fqcn) {
throw new \RuntimeException("Realtime topic class not found: {$topic_class}");
}
if (!is_subclass_of($fqcn, Realtime_Topic_Abstract::class)) {
throw new \RuntimeException("Class {$topic_class} is not a Realtime_Topic_Abstract");
}
// Check permission
if (!$fqcn::can_subscribe($filter)) {
throw new \RuntimeException("Permission denied for topic: {$topic_class}");
}
$payload = [
'topic' => $topic_class,
'filter' => $filter,
'site_id' => Session::get_site_id(),
'exp' => time() + self::TOKEN_EXPIRY,
];
return self::_sign_token($payload);
}
/**
* Publish a message to all subscribers of a topic
*
* Sends via Redis pub/sub to the Node.js WebSocket server.
* Messages are scoped to the current site_id automatically.
*
* IMPORTANT: Never include confidential data in the payload.
* Messages are notifications only — clients fetch fresh data
* through normal Ajax after receiving a notification.
*
* @param string $topic_class Topic class name (e.g., 'Contact_Updated_Topic')
* @param array $data Notification payload (e.g., ['id' => 5, 'updated_by' => 3])
*/
public static function publish(string $topic_class, array $data = []): void
{
if (!self::is_enabled()) {
return;
}
$site_id = Session::get_site_id();
$message = json_encode([
'topic' => $topic_class,
'data' => $data,
'site_id' => $site_id,
'ts' => time(),
]);
$channel = self::REDIS_PREFIX . ':' . $site_id;
Redis::publish($channel, $message);
}
/**
* Sign a payload with HMAC-SHA256
*
* Token format: base64(json_payload).base64(hmac_signature)
*
* @param array $payload Data to sign
* @return string Signed token
*/
private static function _sign_token(array $payload): string
{
$json = json_encode($payload);
$signature = hash_hmac('sha256', $json, config('app.key'));
return base64_encode($json) . '.' . $signature;
}
}