false, 'ERROR' => 'visitor_token_required'], 401); } $visitor = queryOne( "SELECT v.*, il.Token AS InviteToken, il.AllowedChannels, il.HostAddress, il.IsRevoked AS InviteRevoked, il.ExpiresAt AS InviteExpiresAt FROM Hub_Visitors v JOIN Hub_InviteLinks il ON il.ID = v.InviteLinkID WHERE v.VisitorToken = ? LIMIT 1", [$token] ); if (!$visitor) { jsonResponse(['OK' => false, 'ERROR' => 'invalid_visitor_token'], 401); } // Check if the underlying invite link is still valid if ($visitor['InviteRevoked']) { jsonResponse(['OK' => false, 'ERROR' => 'invite_revoked'], 403); } if ($visitor['InviteExpiresAt'] && strtotime($visitor['InviteExpiresAt']) < time()) { jsonResponse(['OK' => false, 'ERROR' => 'invite_expired'], 403); } // Update last active queryTimed("UPDATE Hub_Visitors SET LastActiveAt = NOW() WHERE ID = ?", [$visitor['ID']]); return $visitor; } /** * Get the allowed channel IDs for a visitor. */ function getVisitorAllowedChannels(array $visitor): array { $channels = json_decode($visitor['AllowedChannels'], true); return is_array($channels) ? array_map('intval', $channels) : []; } /** * Check if a visitor can read a specific channel. */ function visitorCanReadChannel(array $visitor, int $channelId): bool { $allowed = getVisitorAllowedChannels($visitor); return in_array($channelId, $allowed, true); } /** * Check rate limit for a visitor. Aborts with 429 if exceeded. */ function checkVisitorRateLimit(int $visitorId): void { // Current window start (rounded to 5-min intervals) $windowSec = VCGW_RATE_LIMIT_WINDOW_SEC; $windowStart = date('Y-m-d H:i:s', (int)(floor(time() / $windowSec) * $windowSec)); $row = queryOne( "SELECT MessageCount FROM Hub_VisitorRateLimit WHERE VisitorID = ? AND WindowStart = ?", [$visitorId, $windowStart] ); $currentCount = $row ? (int)$row['MessageCount'] : 0; if ($currentCount >= VCGW_RATE_LIMIT_MAX) { $resetAt = (int)(floor(time() / $windowSec) * $windowSec) + $windowSec; jsonResponse([ 'OK' => false, 'ERROR' => 'rate_limit_exceeded', 'Limit' => VCGW_RATE_LIMIT_MAX, 'WindowSeconds' => $windowSec, 'ResetsAt' => toISO8601(date('Y-m-d H:i:s', $resetAt)), ], 429); } // Upsert the count queryTimed( "INSERT INTO Hub_VisitorRateLimit (VisitorID, WindowStart, MessageCount) VALUES (?, ?, 1) ON DUPLICATE KEY UPDATE MessageCount = MessageCount + 1", [$visitorId, $windowStart] ); } /** * Require that the caller is a Sprinter agent (via X-Agent-Address header). * Returns the agent address string. */ function requireAgentAuth(): string { $address = headerValue('X-Agent-Address'); if (empty($address)) { jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required'], 401); } // Verify the agent exists in Sprinter_Agents $agent = queryOne( "SELECT ID, FullAddress FROM Sprinter_Agents WHERE FullAddress = ? AND IsActive = 1 LIMIT 1", [$address] ); if (!$agent) { jsonResponse(['OK' => false, 'ERROR' => 'agent_not_found'], 401); } return $agent['FullAddress']; }