payfrit-api/api/hub/vcgateway/helpers.php
Mike cd373dd616 Add VC Gateway endpoints for invite links, visitor auth, DM, and rate limiting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:34:52 +00:00

127 lines
3.9 KiB
PHP

<?php
/**
* VC Gateway Helpers
*
* Shared functions for the VC Gateway module.
* Handles visitor authentication, rate limiting, and invite link validation.
*/
require_once __DIR__ . '/../../helpers.php';
// Rate limit: max messages per window
define('VCGW_RATE_LIMIT_MAX', 10); // 10 messages
define('VCGW_RATE_LIMIT_WINDOW_SEC', 300); // per 5-minute window
/**
* Authenticate a visitor via X-Visitor-Token header.
* Returns the visitor row (with invite link data) or aborts with 401.
*/
function requireVisitorAuth(): array {
$token = headerValue('X-Visitor-Token');
if (empty($token)) {
jsonResponse(['OK' => 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'];
}