🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
156 lines
4.6 KiB
PHP
Executable File
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;
|
|
}
|
|
}
|