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>
This commit is contained in:
parent
c8ac6ae3fa
commit
cd373dd616
12 changed files with 1306 additions and 0 deletions
|
|
@ -558,6 +558,17 @@ const PUBLIC_ROUTES = [
|
|||
'/api/hub/pins/pin.php',
|
||||
'/api/hub/pins/unpin.php',
|
||||
'/api/hub/pins/list.php',
|
||||
// vc gateway - invite management (agent auth)
|
||||
'/api/hub/vcgateway/invites/create.php',
|
||||
'/api/hub/vcgateway/invites/revoke.php',
|
||||
'/api/hub/vcgateway/invites/list.php',
|
||||
'/api/hub/vcgateway/invites/get.php',
|
||||
// vc gateway - visitor endpoints (visitor token auth)
|
||||
'/api/hub/vcgateway/visitor/auth.php',
|
||||
'/api/hub/vcgateway/visitor/feed.php',
|
||||
// vc gateway - DM (visitor token auth)
|
||||
'/api/hub/vcgateway/dm/send.php',
|
||||
'/api/hub/vcgateway/dm/messages.php',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
337
api/hub/vcgateway/API-CONTRACT.md
Normal file
337
api/hub/vcgateway/API-CONTRACT.md
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
# VC Gateway — API Contract
|
||||
|
||||
**Module:** `/api/hub/vcgateway/`
|
||||
**Owner:** @mike (backend), @netasha (frontend)
|
||||
**Status:** Implemented on dev (2026-03-27)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The VC Gateway enables anonymous visitors (e.g. investors) to:
|
||||
1. Authenticate via a shareable invite link (no account needed)
|
||||
2. Read messages from scoped channels (read-only)
|
||||
3. Send DMs to the host (rate-limited)
|
||||
|
||||
Two auth models:
|
||||
- **Agent auth** (`X-Agent-Address` header) — for team members managing invite links
|
||||
- **Visitor auth** (`X-Visitor-Token` header) — for anonymous visitors using the gateway
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Invite Management (Agent Auth)
|
||||
|
||||
#### `POST /api/hub/vcgateway/invites/create.php`
|
||||
|
||||
Create an invite link.
|
||||
|
||||
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"AllowedChannels": [1, 5, 12],
|
||||
"HostAddress": "sprinter.payfrit.john",
|
||||
"Label": "Sequoia - Partner X",
|
||||
"ExpiresAt": "2026-04-15T00:00:00Z",
|
||||
"MaxUses": 3
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| AllowedChannels | int[] | ✅ | Hub channel IDs visitor can read |
|
||||
| HostAddress | string | ✅ | Sprinter agent address for DM target |
|
||||
| Label | string | ❌ | Human label (max 255 chars) |
|
||||
| ExpiresAt | string | ❌ | ISO8601 datetime; null = never |
|
||||
| MaxUses | int | ❌ | 0 = unlimited (default) |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"ID": 1,
|
||||
"Token": "a1b2c3d4e5f6...",
|
||||
"InviteURL": "https://dev.payfrit.com/vc/a1b2c3d4e5f6..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `POST /api/hub/vcgateway/invites/revoke.php`
|
||||
|
||||
Revoke an invite link. All visitor sessions become invalid.
|
||||
|
||||
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{ "ID": 1 }
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "OK": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `GET /api/hub/vcgateway/invites/list.php`
|
||||
|
||||
List invite links with optional status filter.
|
||||
|
||||
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Default | Options |
|
||||
|-------|------|---------|---------|
|
||||
| Status | string | all | `active`, `revoked`, `expired`, `all` |
|
||||
| Limit | int | 50 | max 200 |
|
||||
| Offset | int | 0 | |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"Links": [
|
||||
{
|
||||
"ID": 1,
|
||||
"Token": "a1b2c3...",
|
||||
"Label": "Sequoia - Partner X",
|
||||
"AllowedChannels": [1, 5, 12],
|
||||
"HostAddress": "sprinter.payfrit.john",
|
||||
"ExpiresAt": "2026-04-15T00:00:00Z",
|
||||
"MaxUses": 3,
|
||||
"UseCount": 1,
|
||||
"VisitorCount": 1,
|
||||
"Status": "active",
|
||||
"CreatedBy": "sprinter.payfrit.john",
|
||||
"CreatedAt": "2026-03-27T20:00:00Z"
|
||||
}
|
||||
],
|
||||
"Total": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Status values:** `active`, `revoked`, `expired`, `exhausted`
|
||||
|
||||
---
|
||||
|
||||
#### `GET /api/hub/vcgateway/invites/get.php`
|
||||
|
||||
Get a single invite link with its visitors.
|
||||
|
||||
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
|
||||
|
||||
**Query params:** `ID=1` or `Token=a1b2c3...`
|
||||
|
||||
**Response:** Same as list item, plus a `Visitors` array:
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"Link": {
|
||||
"...": "same fields as list",
|
||||
"Visitors": [
|
||||
{
|
||||
"ID": 1,
|
||||
"DisplayName": "Alex Chen",
|
||||
"CreatedAt": "2026-03-27T21:00:00Z",
|
||||
"LastActiveAt": "2026-03-27T21:15:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Visitor Endpoints (Visitor Auth)
|
||||
|
||||
#### `POST /api/hub/vcgateway/visitor/auth.php`
|
||||
|
||||
Authenticate as a visitor. No auth header needed — uses the invite token.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"InviteToken": "a1b2c3d4e5f6...",
|
||||
"DisplayName": "Alex Chen"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| InviteToken | string | ✅ | Token from invite URL |
|
||||
| DisplayName | string | ❌ | Default: "Visitor" (max 100 chars) |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"VisitorToken": "x9y8z7...",
|
||||
"VisitorID": 1,
|
||||
"DisplayName": "Alex Chen",
|
||||
"AllowedChannels": [
|
||||
{
|
||||
"ID": 1,
|
||||
"Name": "general",
|
||||
"DisplayName": "General",
|
||||
"Purpose": "Team discussion",
|
||||
"ChannelType": "public"
|
||||
}
|
||||
],
|
||||
"HostAddress": "sprinter.payfrit.john"
|
||||
}
|
||||
```
|
||||
|
||||
**Save `VisitorToken` — use it as `X-Visitor-Token` header for all subsequent calls.**
|
||||
|
||||
**Error codes:** `invalid_invite_token`, `invite_revoked`, `invite_expired`, `invite_exhausted`
|
||||
|
||||
---
|
||||
|
||||
#### `GET /api/hub/vcgateway/visitor/feed.php`
|
||||
|
||||
Read-only message feed for an allowed channel.
|
||||
|
||||
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| ChannelID | int | ✅ | Must be in visitor's AllowedChannels |
|
||||
| Before | int | ❌ | Cursor: messages before this ID |
|
||||
| After | int | ❌ | Cursor: messages after this ID |
|
||||
| Limit | int | ❌ | Default 50, max 100 |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"Channel": {
|
||||
"ID": 1,
|
||||
"Name": "general",
|
||||
"DisplayName": "General",
|
||||
"Purpose": "Team discussion"
|
||||
},
|
||||
"Messages": [
|
||||
{
|
||||
"ID": 100,
|
||||
"SenderAddress": "sprinter.payfrit.john",
|
||||
"SenderName": "john",
|
||||
"Content": "Let's discuss the roadmap",
|
||||
"ParentID": null,
|
||||
"IsEdited": false,
|
||||
"CreatedAt": "2026-03-27T20:30:00Z"
|
||||
}
|
||||
],
|
||||
"HasMore": true
|
||||
}
|
||||
```
|
||||
|
||||
**Error codes:** `channel_id_required`, `channel_not_allowed`, `channel_not_found`
|
||||
|
||||
---
|
||||
|
||||
### DM Endpoints (Visitor Auth)
|
||||
|
||||
#### `POST /api/hub/vcgateway/dm/send.php`
|
||||
|
||||
Send a DM from visitor to host. Rate-limited (10 messages per 5 minutes).
|
||||
|
||||
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{ "Content": "Hi, I had a question about your Series A terms." }
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| Content | string | ✅ | Max 4000 chars |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"MessageID": 201,
|
||||
"DMChannelID": 15,
|
||||
"CreatedAt": "2026-03-27T21:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Rate limit error (HTTP 429):**
|
||||
```json
|
||||
{
|
||||
"OK": false,
|
||||
"ERROR": "rate_limit_exceeded",
|
||||
"Limit": 10,
|
||||
"WindowSeconds": 300,
|
||||
"ResetsAt": "2026-03-27T21:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
The DM channel is auto-created on first message. The host sees DMs in their normal Hub message list (channel type: `direct`).
|
||||
|
||||
---
|
||||
|
||||
#### `GET /api/hub/vcgateway/dm/messages.php`
|
||||
|
||||
List messages in the visitor's DM channel.
|
||||
|
||||
**Headers:** `X-Visitor-Token: x9y8z7...`
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Param | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| Before | int | ❌ | Cursor: messages before this ID |
|
||||
| After | int | ❌ | Cursor: messages after this ID |
|
||||
| Limit | int | ❌ | Default 50, max 100 |
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"OK": true,
|
||||
"Messages": [
|
||||
{
|
||||
"ID": 201,
|
||||
"SenderAddress": "visitor:1:Alex Chen",
|
||||
"SenderName": "Alex Chen",
|
||||
"IsVisitor": true,
|
||||
"Content": "Hi, I had a question about your Series A terms.",
|
||||
"IsEdited": false,
|
||||
"CreatedAt": "2026-03-27T21:00:00Z"
|
||||
},
|
||||
{
|
||||
"ID": 205,
|
||||
"SenderAddress": "sprinter.payfrit.john",
|
||||
"SenderName": "john",
|
||||
"IsVisitor": false,
|
||||
"Content": "Sure, happy to discuss. What are you thinking?",
|
||||
"IsEdited": false,
|
||||
"CreatedAt": "2026-03-27T21:02:00Z"
|
||||
}
|
||||
],
|
||||
"DMChannelID": 15,
|
||||
"HasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
Returns empty `Messages` array if no DM channel exists yet.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **Tables:** `Hub_InviteLinks`, `Hub_Visitors`, `Hub_VisitorRateLimit`
|
||||
- **DM channels** use existing `Hub_Channels` (type: `direct`) and `Hub_Messages`
|
||||
- **Rate limiting:** 10 messages per 5-minute sliding window, stored in `Hub_VisitorRateLimit`
|
||||
- **Visitor sender format:** `visitor:{id}:{displayName}` in `SenderAddress`
|
||||
- **Host replies** use normal Hub message send (existing endpoint), targeting the DM channel
|
||||
- **Revoking a link** invalidates all visitor sessions derived from it
|
||||
- **Schema:** `/api/hub/vcgateway/schema.sql`
|
||||
110
api/hub/vcgateway/dm/messages.php
Normal file
110
api/hub/vcgateway/dm/messages.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/vcgateway/dm/messages.php
|
||||
*
|
||||
* List messages in the visitor's DM channel with the host.
|
||||
* Authenticated via X-Visitor-Token header.
|
||||
*
|
||||
* Query params:
|
||||
* Before int optional Cursor: messages before this ID
|
||||
* After int optional Cursor: messages after this ID
|
||||
* Limit int optional Max messages (default: 50, max: 100)
|
||||
*
|
||||
* Response:
|
||||
* OK, Messages[], DMChannelID, HasMore
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$visitor = requireVisitorAuth();
|
||||
|
||||
$dmChannelId = $visitor['DMChannelID'] ? (int)$visitor['DMChannelID'] : null;
|
||||
|
||||
// No DM channel yet = no messages
|
||||
if (!$dmChannelId) {
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Messages' => [],
|
||||
'DMChannelID' => null,
|
||||
'HasMore' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$before = (int)($_GET['Before'] ?? 0);
|
||||
$after = (int)($_GET['After'] ?? 0);
|
||||
$limit = min(100, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||
|
||||
$where = ['m.ChannelID = ?', 'm.IsDeleted = 0'];
|
||||
$params = [$dmChannelId];
|
||||
|
||||
if ($before > 0) {
|
||||
$where[] = 'm.ID < ?';
|
||||
$params[] = $before;
|
||||
}
|
||||
if ($after > 0) {
|
||||
$where[] = 'm.ID > ?';
|
||||
$params[] = $after;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
$fetchLimit = $limit + 1;
|
||||
$order = ($after > 0) ? 'ASC' : 'DESC';
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT m.ID, m.SenderAddress, m.Content, m.IsEdited, m.CreatedAt
|
||||
FROM Hub_Messages m
|
||||
WHERE $whereClause
|
||||
ORDER BY m.ID $order
|
||||
LIMIT $fetchLimit",
|
||||
$params
|
||||
);
|
||||
|
||||
$hasMore = count($rows) > $limit;
|
||||
if ($hasMore) {
|
||||
array_pop($rows);
|
||||
}
|
||||
|
||||
if ($after > 0) {
|
||||
$rows = array_reverse($rows);
|
||||
}
|
||||
|
||||
$visitorId = (int)$visitor['ID'];
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
// Determine if sender is visitor or host
|
||||
$isVisitor = str_starts_with($row['SenderAddress'], 'visitor:');
|
||||
$senderName = $isVisitor ? $visitor['DisplayName'] : $row['SenderAddress'];
|
||||
|
||||
if (!$isVisitor) {
|
||||
// Resolve agent name
|
||||
$agent = queryOne(
|
||||
"SELECT AgentName FROM Sprinter_Agents WHERE FullAddress = ? LIMIT 1",
|
||||
[$row['SenderAddress']]
|
||||
);
|
||||
if ($agent) {
|
||||
$senderName = $agent['AgentName'];
|
||||
}
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'ID' => (int)$row['ID'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'SenderName' => $senderName,
|
||||
'IsVisitor' => $isVisitor,
|
||||
'Content' => $row['Content'],
|
||||
'IsEdited' => (bool)$row['IsEdited'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Messages' => $messages,
|
||||
'DMChannelID' => $dmChannelId,
|
||||
'HasMore' => $hasMore,
|
||||
]);
|
||||
89
api/hub/vcgateway/dm/send.php
Normal file
89
api/hub/vcgateway/dm/send.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/vcgateway/dm/send.php
|
||||
*
|
||||
* Send a DM from visitor to host. Rate-limited.
|
||||
* Creates the DM channel on first message.
|
||||
* Authenticated via X-Visitor-Token header.
|
||||
*
|
||||
* Body:
|
||||
* Content string required Message text (max 4000 chars)
|
||||
*
|
||||
* Response:
|
||||
* OK, MessageID, DMChannelID, CreatedAt
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$visitor = requireVisitorAuth();
|
||||
$body = readJsonBody();
|
||||
|
||||
$content = trim($body['Content'] ?? '');
|
||||
if (empty($content)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'content_required'], 400);
|
||||
}
|
||||
if (strlen($content) > 4000) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'content_too_long', 'MaxLength' => 4000], 400);
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
checkVisitorRateLimit((int)$visitor['ID']);
|
||||
|
||||
$visitorId = (int)$visitor['ID'];
|
||||
$hostAddress = $visitor['HostAddress'];
|
||||
$dmChannelId = $visitor['DMChannelID'] ? (int)$visitor['DMChannelID'] : null;
|
||||
|
||||
// Create DM channel if it doesn't exist yet
|
||||
if (!$dmChannelId) {
|
||||
$channelName = 'vc-dm-visitor-' . $visitorId;
|
||||
$displayName = 'VC DM: ' . $visitor['DisplayName'];
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Channels (Name, DisplayName, Purpose, ChannelType, CreatedBy)
|
||||
VALUES (?, ?, ?, 'direct', ?)",
|
||||
[
|
||||
$channelName,
|
||||
$displayName,
|
||||
'VC Gateway DM between visitor and host',
|
||||
$hostAddress,
|
||||
]
|
||||
);
|
||||
|
||||
$dmChannelId = (int)lastInsertId();
|
||||
|
||||
// Add the host as a member
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role)
|
||||
VALUES (?, ?, 'owner')",
|
||||
[$dmChannelId, $hostAddress]
|
||||
);
|
||||
|
||||
// Link the DM channel to the visitor
|
||||
queryTimed(
|
||||
"UPDATE Hub_Visitors SET DMChannelID = ? WHERE ID = ?",
|
||||
[$dmChannelId, $visitorId]
|
||||
);
|
||||
}
|
||||
|
||||
// Send the message
|
||||
// Visitor sender address format: "visitor:{visitorId}:{displayName}"
|
||||
$senderAddress = 'visitor:' . $visitorId . ':' . $visitor['DisplayName'];
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Messages (ChannelID, SenderAddress, Content)
|
||||
VALUES (?, ?, ?)",
|
||||
[$dmChannelId, $senderAddress, $content]
|
||||
);
|
||||
|
||||
$messageId = (int)lastInsertId();
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'MessageID' => $messageId,
|
||||
'DMChannelID' => $dmChannelId,
|
||||
'CreatedAt' => toISO8601(date('Y-m-d H:i:s')),
|
||||
]);
|
||||
127
api/hub/vcgateway/helpers.php
Normal file
127
api/hub/vcgateway/helpers.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?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'];
|
||||
}
|
||||
96
api/hub/vcgateway/invites/create.php
Normal file
96
api/hub/vcgateway/invites/create.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/vcgateway/invites/create.php
|
||||
*
|
||||
* Create an invite link for VC Gateway.
|
||||
* Requires agent auth (X-Agent-Address header).
|
||||
*
|
||||
* Body:
|
||||
* Label string optional Human label (e.g. "Sequoia - Partner X")
|
||||
* AllowedChannels int[] required Array of Hub_Channels.ID the visitor can read
|
||||
* HostAddress string required Sprinter agent address for DM target
|
||||
* ExpiresAt string optional ISO8601 expiration datetime (null = never)
|
||||
* MaxUses int optional Max times this link can be used (0 = unlimited)
|
||||
*
|
||||
* Response:
|
||||
* OK, ID, Token, InviteURL
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$agentAddress = requireAgentAuth();
|
||||
$body = readJsonBody();
|
||||
|
||||
// Validate required fields
|
||||
$allowedChannels = $body['AllowedChannels'] ?? null;
|
||||
$hostAddress = trim($body['HostAddress'] ?? '');
|
||||
|
||||
if (!is_array($allowedChannels) || empty($allowedChannels)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'allowed_channels_required'], 400);
|
||||
}
|
||||
|
||||
if (empty($hostAddress)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'host_address_required'], 400);
|
||||
}
|
||||
|
||||
// Validate all channel IDs exist
|
||||
$channelIds = array_map('intval', $allowedChannels);
|
||||
$placeholders = implode(',', array_fill(0, count($channelIds), '?'));
|
||||
$channels = queryTimed(
|
||||
"SELECT ID FROM Hub_Channels WHERE ID IN ($placeholders)",
|
||||
$channelIds
|
||||
);
|
||||
|
||||
$foundIds = array_column($channels, 'ID');
|
||||
$missing = array_diff($channelIds, array_map('intval', $foundIds));
|
||||
if (!empty($missing)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_channel_ids', 'InvalidIDs' => array_values($missing)], 400);
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
$label = trim($body['Label'] ?? '');
|
||||
if (strlen($label) > 255) {
|
||||
$label = substr($label, 0, 255);
|
||||
}
|
||||
|
||||
$expiresAt = null;
|
||||
if (!empty($body['ExpiresAt'])) {
|
||||
$dt = strtotime($body['ExpiresAt']);
|
||||
if ($dt === false || $dt <= time()) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'expires_at_must_be_future'], 400);
|
||||
}
|
||||
$expiresAt = date('Y-m-d H:i:s', $dt);
|
||||
}
|
||||
|
||||
$maxUses = max(0, (int)($body['MaxUses'] ?? 0));
|
||||
|
||||
// Generate a URL-safe token
|
||||
$token = bin2hex(random_bytes(24)); // 48 chars, URL-safe
|
||||
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_InviteLinks (Token, Label, AllowedChannels, HostAddress, ExpiresAt, MaxUses, CreatedBy)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
$token,
|
||||
$label,
|
||||
json_encode($channelIds),
|
||||
$hostAddress,
|
||||
$expiresAt,
|
||||
$maxUses,
|
||||
$agentAddress,
|
||||
]
|
||||
);
|
||||
|
||||
$id = (int)lastInsertId();
|
||||
$inviteUrl = baseUrl() . '/vc/' . $token;
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'ID' => $id,
|
||||
'Token' => $token,
|
||||
'InviteURL' => $inviteUrl,
|
||||
]);
|
||||
92
api/hub/vcgateway/invites/get.php
Normal file
92
api/hub/vcgateway/invites/get.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/vcgateway/invites/get.php
|
||||
*
|
||||
* Get a single invite link by ID or Token.
|
||||
* Requires agent auth (X-Agent-Address header).
|
||||
*
|
||||
* Query params:
|
||||
* ID int optional Invite link ID
|
||||
* Token string optional Invite link token
|
||||
*
|
||||
* Response:
|
||||
* OK, Link (object)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$agentAddress = requireAgentAuth();
|
||||
|
||||
$id = (int)($_GET['ID'] ?? 0);
|
||||
$token = trim($_GET['Token'] ?? '');
|
||||
|
||||
if ($id <= 0 && empty($token)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'id_or_token_required'], 400);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$row = queryOne("SELECT * FROM Hub_InviteLinks WHERE ID = ?", [$id]);
|
||||
} else {
|
||||
$row = queryOne("SELECT * FROM Hub_InviteLinks WHERE Token = ?", [$token]);
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_not_found'], 404);
|
||||
}
|
||||
|
||||
// Get visitor count
|
||||
$vcRow = queryOne(
|
||||
"SELECT COUNT(*) AS cnt FROM Hub_Visitors WHERE InviteLinkID = ?",
|
||||
[(int)$row['ID']]
|
||||
);
|
||||
|
||||
// Compute status
|
||||
$computedStatus = 'active';
|
||||
if ($row['IsRevoked']) {
|
||||
$computedStatus = 'revoked';
|
||||
} elseif ($row['ExpiresAt'] && strtotime($row['ExpiresAt']) <= time()) {
|
||||
$computedStatus = 'expired';
|
||||
} elseif ($row['MaxUses'] > 0 && $row['UseCount'] >= $row['MaxUses']) {
|
||||
$computedStatus = 'exhausted';
|
||||
}
|
||||
|
||||
// Get visitors using this link
|
||||
$visitors = queryTimed(
|
||||
"SELECT ID, DisplayName, CreatedAt, LastActiveAt
|
||||
FROM Hub_Visitors WHERE InviteLinkID = ?
|
||||
ORDER BY CreatedAt DESC",
|
||||
[(int)$row['ID']]
|
||||
);
|
||||
|
||||
$visitorList = [];
|
||||
foreach ($visitors as $v) {
|
||||
$visitorList[] = [
|
||||
'ID' => (int)$v['ID'],
|
||||
'DisplayName' => $v['DisplayName'],
|
||||
'CreatedAt' => toISO8601($v['CreatedAt']),
|
||||
'LastActiveAt' => toISO8601($v['LastActiveAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Link' => [
|
||||
'ID' => (int)$row['ID'],
|
||||
'Token' => $row['Token'],
|
||||
'Label' => $row['Label'],
|
||||
'AllowedChannels' => json_decode($row['AllowedChannels'], true),
|
||||
'HostAddress' => $row['HostAddress'],
|
||||
'ExpiresAt' => $row['ExpiresAt'] ? toISO8601($row['ExpiresAt']) : null,
|
||||
'MaxUses' => (int)$row['MaxUses'],
|
||||
'UseCount' => (int)$row['UseCount'],
|
||||
'VisitorCount' => (int)($vcRow['cnt'] ?? 0),
|
||||
'Status' => $computedStatus,
|
||||
'CreatedBy' => $row['CreatedBy'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
'Visitors' => $visitorList,
|
||||
],
|
||||
]);
|
||||
104
api/hub/vcgateway/invites/list.php
Normal file
104
api/hub/vcgateway/invites/list.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/vcgateway/invites/list.php
|
||||
*
|
||||
* List invite links. Optionally filter by status.
|
||||
* Requires agent auth (X-Agent-Address header).
|
||||
*
|
||||
* Query params:
|
||||
* Status string optional "active" | "revoked" | "expired" | "all" (default: "all")
|
||||
* Limit int optional Max results (default: 50, max: 200)
|
||||
* Offset int optional Pagination offset (default: 0)
|
||||
*
|
||||
* Response:
|
||||
* OK, Links[], Total
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$agentAddress = requireAgentAuth();
|
||||
|
||||
$status = strtolower(trim($_GET['Status'] ?? 'all'));
|
||||
$limit = min(200, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||
$offset = max(0, (int)($_GET['Offset'] ?? 0));
|
||||
|
||||
// Build WHERE clause
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
switch ($status) {
|
||||
case 'active':
|
||||
$where[] = 'il.IsRevoked = 0';
|
||||
$where[] = '(il.ExpiresAt IS NULL OR il.ExpiresAt > NOW())';
|
||||
$where[] = '(il.MaxUses = 0 OR il.UseCount < il.MaxUses)';
|
||||
break;
|
||||
case 'revoked':
|
||||
$where[] = 'il.IsRevoked = 1';
|
||||
break;
|
||||
case 'expired':
|
||||
$where[] = 'il.IsRevoked = 0';
|
||||
$where[] = 'il.ExpiresAt IS NOT NULL AND il.ExpiresAt <= NOW()';
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
// No filter
|
||||
break;
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
// Count total
|
||||
$countRow = queryOne(
|
||||
"SELECT COUNT(*) AS cnt FROM Hub_InviteLinks il $whereClause",
|
||||
$params
|
||||
);
|
||||
$total = (int)($countRow['cnt'] ?? 0);
|
||||
|
||||
// Fetch links
|
||||
$rows = queryTimed(
|
||||
"SELECT il.*,
|
||||
(SELECT COUNT(*) FROM Hub_Visitors v WHERE v.InviteLinkID = il.ID) AS VisitorCount
|
||||
FROM Hub_InviteLinks il
|
||||
$whereClause
|
||||
ORDER BY il.CreatedAt DESC
|
||||
LIMIT $limit OFFSET $offset",
|
||||
$params
|
||||
);
|
||||
|
||||
$links = [];
|
||||
foreach ($rows as $row) {
|
||||
// Determine computed status
|
||||
$computedStatus = 'active';
|
||||
if ($row['IsRevoked']) {
|
||||
$computedStatus = 'revoked';
|
||||
} elseif ($row['ExpiresAt'] && strtotime($row['ExpiresAt']) <= time()) {
|
||||
$computedStatus = 'expired';
|
||||
} elseif ($row['MaxUses'] > 0 && $row['UseCount'] >= $row['MaxUses']) {
|
||||
$computedStatus = 'exhausted';
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'ID' => (int)$row['ID'],
|
||||
'Token' => $row['Token'],
|
||||
'Label' => $row['Label'],
|
||||
'AllowedChannels' => json_decode($row['AllowedChannels'], true),
|
||||
'HostAddress' => $row['HostAddress'],
|
||||
'ExpiresAt' => $row['ExpiresAt'] ? toISO8601($row['ExpiresAt']) : null,
|
||||
'MaxUses' => (int)$row['MaxUses'],
|
||||
'UseCount' => (int)$row['UseCount'],
|
||||
'VisitorCount' => (int)$row['VisitorCount'],
|
||||
'Status' => $computedStatus,
|
||||
'CreatedBy' => $row['CreatedBy'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Links' => $links,
|
||||
'Total' => $total,
|
||||
]);
|
||||
48
api/hub/vcgateway/invites/revoke.php
Normal file
48
api/hub/vcgateway/invites/revoke.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/vcgateway/invites/revoke.php
|
||||
*
|
||||
* Revoke an invite link. All visitor sessions using this link become invalid.
|
||||
* Requires agent auth (X-Agent-Address header).
|
||||
*
|
||||
* Body:
|
||||
* ID int required The invite link ID to revoke
|
||||
*
|
||||
* Response:
|
||||
* OK
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$agentAddress = requireAgentAuth();
|
||||
$body = readJsonBody();
|
||||
|
||||
$id = (int)($body['ID'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'id_required'], 400);
|
||||
}
|
||||
|
||||
// Verify the link exists and belongs to this agent (or they created it)
|
||||
$link = queryOne(
|
||||
"SELECT ID, CreatedBy, IsRevoked FROM Hub_InviteLinks WHERE ID = ?",
|
||||
[$id]
|
||||
);
|
||||
|
||||
if (!$link) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_not_found'], 404);
|
||||
}
|
||||
|
||||
if ($link['IsRevoked']) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'already_revoked'], 400);
|
||||
}
|
||||
|
||||
queryTimed(
|
||||
"UPDATE Hub_InviteLinks SET IsRevoked = 1 WHERE ID = ?",
|
||||
[$id]
|
||||
);
|
||||
|
||||
jsonResponse(['OK' => true]);
|
||||
51
api/hub/vcgateway/schema.sql
Normal file
51
api/hub/vcgateway/schema.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- VC Gateway Schema
|
||||
-- Invite links, visitor sessions, and DM support for anonymous visitors.
|
||||
-- Part of Sprinter Hub - supports fundraising / investor access.
|
||||
|
||||
-- Invite links with expiration and channel scoping
|
||||
CREATE TABLE IF NOT EXISTS Hub_InviteLinks (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
Token VARCHAR(64) NOT NULL COMMENT 'URL-safe token for link',
|
||||
Label VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Human label e.g. investor name',
|
||||
AllowedChannels JSON NOT NULL COMMENT 'Array of Hub_Channels.ID the visitor can read',
|
||||
HostAddress VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address of the host (DM target)',
|
||||
ExpiresAt DATETIME NULL COMMENT 'NULL = never expires',
|
||||
MaxUses INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 = unlimited',
|
||||
UseCount INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
IsRevoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
CreatedBy VARCHAR(150) NOT NULL COMMENT 'Agent who created the link',
|
||||
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY uq_invite_token (Token),
|
||||
INDEX idx_created_by (CreatedBy),
|
||||
INDEX idx_revoked (IsRevoked),
|
||||
INDEX idx_expires (ExpiresAt)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Visitor sessions - created when someone authenticates via invite link
|
||||
CREATE TABLE IF NOT EXISTS Hub_Visitors (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
InviteLinkID INT UNSIGNED NOT NULL,
|
||||
VisitorToken VARCHAR(64) NOT NULL COMMENT 'Session token for visitor API calls',
|
||||
DisplayName VARCHAR(100) NOT NULL DEFAULT 'Visitor',
|
||||
DMChannelID INT UNSIGNED NULL COMMENT 'Hub_Channels.ID for visitor<->host DM',
|
||||
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
LastActiveAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE KEY uq_visitor_token (VisitorToken),
|
||||
INDEX idx_invite_link (InviteLinkID),
|
||||
INDEX idx_dm_channel (DMChannelID),
|
||||
CONSTRAINT fk_visitor_invite FOREIGN KEY (InviteLinkID) REFERENCES Hub_InviteLinks(ID) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Rate limiting: track visitor message counts per sliding window
|
||||
CREATE TABLE IF NOT EXISTS Hub_VisitorRateLimit (
|
||||
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
VisitorID INT UNSIGNED NOT NULL,
|
||||
WindowStart DATETIME NOT NULL,
|
||||
MessageCount INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
|
||||
UNIQUE KEY uq_visitor_window (VisitorID, WindowStart),
|
||||
CONSTRAINT fk_ratelimit_visitor FOREIGN KEY (VisitorID) REFERENCES Hub_Visitors(ID) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
113
api/hub/vcgateway/visitor/auth.php
Normal file
113
api/hub/vcgateway/visitor/auth.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
/**
|
||||
* POST /api/hub/vcgateway/visitor/auth.php
|
||||
*
|
||||
* Authenticate as a visitor using an invite link token.
|
||||
* Creates a visitor session and returns a VisitorToken for subsequent API calls.
|
||||
* No user account or Sprinter agent required.
|
||||
*
|
||||
* Body:
|
||||
* InviteToken string required The invite link token from the URL
|
||||
* DisplayName string optional Visitor's display name (default: "Visitor")
|
||||
*
|
||||
* Response:
|
||||
* OK, VisitorToken, VisitorID, DisplayName, AllowedChannels[], HostAddress
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$body = readJsonBody();
|
||||
|
||||
$inviteToken = trim($body['InviteToken'] ?? '');
|
||||
if (empty($inviteToken)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_token_required'], 400);
|
||||
}
|
||||
|
||||
// Look up the invite link
|
||||
$link = queryOne(
|
||||
"SELECT * FROM Hub_InviteLinks WHERE Token = ? LIMIT 1",
|
||||
[$inviteToken]
|
||||
);
|
||||
|
||||
if (!$link) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invalid_invite_token'], 404);
|
||||
}
|
||||
|
||||
// Check if revoked
|
||||
if ($link['IsRevoked']) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_revoked'], 403);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if ($link['ExpiresAt'] && strtotime($link['ExpiresAt']) < time()) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_expired'], 403);
|
||||
}
|
||||
|
||||
// Check max uses
|
||||
if ($link['MaxUses'] > 0 && $link['UseCount'] >= $link['MaxUses']) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'invite_exhausted'], 403);
|
||||
}
|
||||
|
||||
// Sanitize display name
|
||||
$displayName = trim($body['DisplayName'] ?? '');
|
||||
if (empty($displayName)) {
|
||||
$displayName = 'Visitor';
|
||||
}
|
||||
$displayName = substr($displayName, 0, 100);
|
||||
// Strip any HTML/script
|
||||
$displayName = htmlspecialchars($displayName, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Generate visitor token
|
||||
$visitorToken = bin2hex(random_bytes(24));
|
||||
|
||||
// Create the visitor session
|
||||
queryTimed(
|
||||
"INSERT INTO Hub_Visitors (InviteLinkID, VisitorToken, DisplayName)
|
||||
VALUES (?, ?, ?)",
|
||||
[(int)$link['ID'], $visitorToken, $displayName]
|
||||
);
|
||||
|
||||
$visitorId = (int)lastInsertId();
|
||||
|
||||
// Increment use count on the invite link
|
||||
queryTimed(
|
||||
"UPDATE Hub_InviteLinks SET UseCount = UseCount + 1 WHERE ID = ?",
|
||||
[(int)$link['ID']]
|
||||
);
|
||||
|
||||
// Return the session info
|
||||
$allowedChannels = json_decode($link['AllowedChannels'], true);
|
||||
|
||||
// Fetch channel details for the allowed channels
|
||||
$channelDetails = [];
|
||||
if (!empty($allowedChannels)) {
|
||||
$placeholders = implode(',', array_fill(0, count($allowedChannels), '?'));
|
||||
$channels = queryTimed(
|
||||
"SELECT ID, Name, DisplayName, Purpose, ChannelType
|
||||
FROM Hub_Channels
|
||||
WHERE ID IN ($placeholders) AND IsArchived = 0",
|
||||
array_map('intval', $allowedChannels)
|
||||
);
|
||||
foreach ($channels as $ch) {
|
||||
$channelDetails[] = [
|
||||
'ID' => (int)$ch['ID'],
|
||||
'Name' => $ch['Name'],
|
||||
'DisplayName' => $ch['DisplayName'],
|
||||
'Purpose' => $ch['Purpose'],
|
||||
'ChannelType' => $ch['ChannelType'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'VisitorToken' => $visitorToken,
|
||||
'VisitorID' => $visitorId,
|
||||
'DisplayName' => $displayName,
|
||||
'AllowedChannels' => $channelDetails,
|
||||
'HostAddress' => $link['HostAddress'],
|
||||
]);
|
||||
128
api/hub/vcgateway/visitor/feed.php
Normal file
128
api/hub/vcgateway/visitor/feed.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
/**
|
||||
* GET /api/hub/vcgateway/visitor/feed.php
|
||||
*
|
||||
* Read-only message feed for a visitor's allowed channels.
|
||||
* Authenticated via X-Visitor-Token header (not user token).
|
||||
*
|
||||
* Query params:
|
||||
* ChannelID int required Channel to read messages from
|
||||
* Before int optional Cursor: return messages before this ID
|
||||
* After int optional Cursor: return messages after this ID
|
||||
* Limit int optional Max messages (default: 50, max: 100)
|
||||
*
|
||||
* Response:
|
||||
* OK, Messages[], Channel (metadata), HasMore
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$visitor = requireVisitorAuth();
|
||||
|
||||
$channelId = (int)($_GET['ChannelID'] ?? 0);
|
||||
if ($channelId <= 0) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required'], 400);
|
||||
}
|
||||
|
||||
// Verify visitor has access to this channel
|
||||
if (!visitorCanReadChannel($visitor, $channelId)) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_allowed'], 403);
|
||||
}
|
||||
|
||||
// Verify channel exists and is not archived
|
||||
$channel = queryOne(
|
||||
"SELECT ID, Name, DisplayName, Purpose, ChannelType
|
||||
FROM Hub_Channels WHERE ID = ? AND IsArchived = 0",
|
||||
[$channelId]
|
||||
);
|
||||
|
||||
if (!$channel) {
|
||||
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$before = (int)($_GET['Before'] ?? 0);
|
||||
$after = (int)($_GET['After'] ?? 0);
|
||||
$limit = min(100, max(1, (int)($_GET['Limit'] ?? 50)));
|
||||
|
||||
// Build query - read only non-deleted messages
|
||||
$where = ['m.ChannelID = ?', 'm.IsDeleted = 0'];
|
||||
$params = [$channelId];
|
||||
|
||||
if ($before > 0) {
|
||||
$where[] = 'm.ID < ?';
|
||||
$params[] = $before;
|
||||
}
|
||||
if ($after > 0) {
|
||||
$where[] = 'm.ID > ?';
|
||||
$params[] = $after;
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
|
||||
// Fetch one extra to check HasMore
|
||||
$fetchLimit = $limit + 1;
|
||||
|
||||
// Order: newest first for Before cursor, oldest first for After cursor
|
||||
$order = ($after > 0) ? 'ASC' : 'DESC';
|
||||
|
||||
$rows = queryTimed(
|
||||
"SELECT m.ID, m.SenderAddress, m.Content, m.ParentID,
|
||||
m.IsEdited, m.CreatedAt, m.UpdatedAt
|
||||
FROM Hub_Messages m
|
||||
WHERE $whereClause
|
||||
ORDER BY m.ID $order
|
||||
LIMIT $fetchLimit",
|
||||
$params
|
||||
);
|
||||
|
||||
$hasMore = count($rows) > $limit;
|
||||
if ($hasMore) {
|
||||
array_pop($rows);
|
||||
}
|
||||
|
||||
// If we fetched in ASC order, reverse for consistent newest-first
|
||||
if ($after > 0) {
|
||||
$rows = array_reverse($rows);
|
||||
}
|
||||
|
||||
// Format messages (read-only: no edit/delete capabilities for visitors)
|
||||
$messages = [];
|
||||
foreach ($rows as $row) {
|
||||
// Resolve sender display name
|
||||
$senderName = $row['SenderAddress'];
|
||||
// Try to get agent name
|
||||
$agent = queryOne(
|
||||
"SELECT AgentName FROM Sprinter_Agents WHERE FullAddress = ? LIMIT 1",
|
||||
[$row['SenderAddress']]
|
||||
);
|
||||
if ($agent) {
|
||||
$senderName = $agent['AgentName'];
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'ID' => (int)$row['ID'],
|
||||
'SenderAddress' => $row['SenderAddress'],
|
||||
'SenderName' => $senderName,
|
||||
'Content' => $row['Content'],
|
||||
'ParentID' => $row['ParentID'] ? (int)$row['ParentID'] : null,
|
||||
'IsEdited' => (bool)$row['IsEdited'],
|
||||
'CreatedAt' => toISO8601($row['CreatedAt']),
|
||||
];
|
||||
}
|
||||
|
||||
jsonResponse([
|
||||
'OK' => true,
|
||||
'Channel' => [
|
||||
'ID' => (int)$channel['ID'],
|
||||
'Name' => $channel['Name'],
|
||||
'DisplayName' => $channel['DisplayName'],
|
||||
'Purpose' => $channel['Purpose'],
|
||||
],
|
||||
'Messages' => $messages,
|
||||
'HasMore' => $hasMore,
|
||||
]);
|
||||
Loading…
Add table
Reference in a new issue