Added chat server selection endpoints.

This commit is contained in:
flash 2025-04-22 21:55:18 +00:00
parent 52632b42eb
commit 3bd556fba9
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
6 changed files with 321 additions and 3 deletions

View file

@ -1 +1 @@
20250422
20250422.1

View file

@ -0,0 +1,23 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class CreateChatServersTable_20250422_203044 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute(<<<SQL
CREATE TABLE msz_chat_servers (
server_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
server_order INT(11) NOT NULL DEFAULT '0',
server_proto VARCHAR(255) NOT NULL COLLATE 'ascii_general_ci',
server_secure TINYINT(3) UNSIGNED NOT NULL,
server_address VARCHAR(255) NOT NULL COLLATE 'ascii_bin',
server_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (server_id),
KEY msz_chat_servers_order_index (server_order),
KEY msz_chat_servers_proto_index (server_proto),
KEY msz_chat_servers_secure_index (server_secure),
KEY msz_chat_servers_created_index (server_created)
) COLLATE='utf8mb4_bin' ENGINE=InnoDB
SQL);
}
}

View file

@ -2,12 +2,15 @@
namespace Misuzu\Chat;
use InvalidArgumentException;
use RuntimeException;
use Index\XArray;
use Index\Config\Config;
use Index\Http\{HttpRequest,HttpResponseBuilder,HttpUri};
use Index\Http\Routing\AccessControl\AccessControl;
use Index\Http\Routing\Processors\Before;
use Index\Http\Routing\Routes\ExactRoute;
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
use Misuzu\FieldTransformer;
use Misuzu\Auth\{AuthContext,AuthInfo};
use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
@ -67,4 +70,120 @@ final class ChatApiRoutes implements RouteHandler, UrlSource {
'access_token' => $this->authCtx->createAuthTokenPacker()->pack($this->authInfo->tokenInfo),
];
}
/** @return FieldTransformer<ChatServerInfo> */
private function createServerTransformer(bool $protoDefault = true, bool $secureDefault = true): FieldTransformer {
return new FieldTransformer([
'id' => [
'transform' => fn($server) => $server->id,
],
'order' => [
'transform' => fn($server) => $server->order,
],
'proto' => [
'default' => $protoDefault,
'transform' => fn($server) => $server->proto,
],
'secure' => [
'default' => $secureDefault,
'transform' => fn($server) => $server->secure,
],
'uri' => [
'default' => true,
'transform' => fn($server) => $server->uri,
],
'created' => [
'transform' => fn($server) => $server->createdAt->toIso8601ZuluString(),
],
]);
}
/** @return int|mixed[] */
#[AccessControl]
#[ExactRoute('GET', '/api/v1/chat/servers')]
public function getServers(HttpResponseBuilder $response, HttpRequest $request): array|int {
$proto = $request->hasParam('proto') ? $request->getParam('proto') : null;
$secure = $request->hasParam('secure') ? match($request->getParam('secure')) {
'1' => true,
'0' => false,
default => 0,
} : null;
if($secure === 0)
return 400;
$transformer = $this->createServerTransformer(
$proto === null,
$secure === null,
);
if(!$transformer->filter($request))
return 400;
$response->setCacheControl('max-age=300', 'stale-if-error=600', 'public', 'immutable');
return XArray::select(
$this->chatCtx->servers->getServers(
proto: $proto,
secure: $secure,
),
fn($server) => $transformer->convert($server),
);
}
/** @return int|mixed[] */
#[AccessControl]
#[PatternRoute('GET', '/api/v1/chat/servers/([0-9]+)')]
public function getServer(HttpResponseBuilder $response, HttpRequest $request, string $id): array|int {
if(empty($id))
return 404;
$transformer = $this->createServerTransformer();
if(!$transformer->filter($request))
return 400;
try {
$server = $this->chatCtx->servers->getServer($id);
} catch(RuntimeException $ex) {
return 404;
}
$response->setCacheControl('max-age=300', 'stale-if-error=600', 'public', 'immutable');
return $transformer->convert($server);
}
/** @return int|mixed[] */
#[AccessControl]
#[ExactRoute('GET', '/api/v1/chat/servers/recommended')]
public function getRecommendedServer(HttpResponseBuilder $response, HttpRequest $request): array|int {
if(!$request->hasParam('proto'))
return 400;
$proto = $request->getParam('proto');
if($request->hasParam('secure')) {
$secure = match($request->getParam('secure')) {
'1' => true,
'0' => false,
default => null,
};
if($secure === null)
return 400;
} else
$secure = $request->secure;
$transformer = $this->createServerTransformer(false, false);
if(!$transformer->filter($request))
return 400;
$servers = iterator_to_array($this->chatCtx->servers->getServers(
proto: $proto,
secure: $secure,
), false);
$response->setCacheControl('max-age=300', 'stale-if-error=600', 'public', 'immutable');
// the algo of all time
return $transformer->convert($servers[array_rand($servers)]);
}
}

View file

@ -2,9 +2,12 @@
namespace Misuzu\Chat;
use Index\Config\Config;
use Index\Db\DbConnection;
use Index\Http\HttpUri;
class ChatContext {
public private(set) ChatServersData $servers;
/** @var string[] */
public array $origins {
get => $this->config->getArray('origins');
@ -16,7 +19,10 @@ class ChatContext {
public function __construct(
public private(set) Config $config,
) {}
DbConnection $dbConn,
) {
$this->servers = new ChatServersData($dbConn);
}
public function isTrustedHost(HttpUri|string $host): bool {
if($host instanceof HttpUri)

View file

@ -0,0 +1,31 @@
<?php
namespace Misuzu\Chat;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class ChatServerInfo {
public function __construct(
public private(set) string $id,
public private(set) int $order,
public private(set) string $proto,
public private(set) bool $secure,
public private(set) string $uri,
public private(set) int $createdTime,
) {}
public static function fromResult(DbResult $result): ChatServerInfo {
return new ChatServerInfo(
id: $result->getString(0),
order: $result->getInteger(1),
proto: $result->getString(2),
secure: $result->getBoolean(3),
uri: $result->getString(4),
createdTime: $result->getInteger(5),
);
}
public CarbonImmutable $createdAt {
get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
}
}

View file

@ -0,0 +1,139 @@
<?php
namespace Misuzu\Chat;
use RuntimeException;
use Index\Db\{DbConnection,DbStatementCache};
class ChatServersData {
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
/** @return iterable<ChatServerInfo> */
public function getServers(
?string $proto = null,
?bool $secure = null,
): iterable {
$hasProto = $proto !== null;
$args = 0;
$query = <<<SQL
SELECT server_id, server_order, server_proto, server_secure, server_uri, UNIX_TIMESTAMP(server_created)
FROM msz_chat_servers
SQL;
if($hasProto) {
++$args;
$query .= ' WHERE server_proto = ?';
}
if($secure !== null)
$query .= sprintf(
' %s server_secure %s 0',
++$args > 1 ? 'AND' : 'WHERE',
$secure ? '<>' : '='
);
$query .= ' ORDER BY server_order';
$stmt = $this->cache->get($query);
if($hasProto)
$stmt->nextParameter($proto);
$stmt->execute();
return $stmt->getResultIterator(ChatServerInfo::fromResult(...));
}
public function getServer(string $serverId): ChatServerInfo {
$stmt = $this->cache->get(<<<SQL
SELECT server_id, server_order, server_proto, server_secure, server_uri, UNIX_TIMESTAMP(server_created)
FROM msz_chat_servers
WHERE server_id = ?
SQL);
$stmt->nextParameter($serverId);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('could not find that server');
return ChatServerInfo::fromResult($result);
}
public function deleteServer(ChatServerInfo|string $serverInfo): void {
$stmt = $this->cache->get(<<<SQL
DELETE FROM msz_chat_servers
WHERE server_id = ?
SQL);
$stmt->nextParameter($serverInfo instanceof ChatServerInfo ? $serverInfo->id : $serverInfo);
$stmt->execute();
}
public function createServer(
string $proto,
bool $secure,
string $uri,
?int $order = null,
): ChatServerInfo {
$stmt = $this->cache->get(<<<SQL
INSERT INTO msz_chat_servers (server_proto, server_secure, server_uri, server_order)
SELECT ?, ?, ?, COALESCE(?, (
SELECT FLOOR(MAX(server_order) / 10) * 10 + 10
FROM msz_chat_servers
WHERE server_proto = ?
AND server_secure = ?
), 10)
SQL);
$stmt->nextParameter($proto);
$stmt->nextParameter($secure);
$stmt->nextParameter($uri);
$stmt->nextParameter($order);
$stmt->nextParameter($proto);
$stmt->nextParameter($secure);
$stmt->execute();
return $this->getServer((string)$stmt->lastInsertId);
}
public function updateServer(
ChatServerInfo|string $serverInfo,
?string $proto = null,
?bool $secure = null,
?string $uri = null,
?int $order = null,
): void {
$fields = [];
$values = [];
if($proto !== null) {
$fields[] = 'server_proto = ?';
$values[] = $proto;
}
if($secure !== null)
$fields[] = sprintf('server_secure = %d', $secure ? 1 : 0);
if($uri !== null) {
$fields[] = 'server_uri = ?';
$values[] = $uri;
}
if($order !== null) {
$fields[] = 'server_order = ?';
$values[] = $order;
}
if(empty($fields))
return;
$fields = implode(', ', $fields);
$stmt = $this->cache->get(<<<SQL
UPDATE msz_chat_servers
SET {$fields}
WHERE server_id = ?
SQL);
foreach($values as $value)
$stmt->nextParameter($value);
$stmt->nextParameter($serverInfo instanceof ChatServerInfo ? $serverInfo->id : $serverInfo);
$stmt->execute();
}
}