$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; } }