Initial pile of code (untested!)

This commit is contained in:
flash 2024-08-13 00:54:50 +00:00
commit 05e707be51
23 changed files with 2848 additions and 0 deletions

5
.gitattributes vendored Normal file
View file

@ -0,0 +1,5 @@
* text=auto
*.sh text eol=lf
*.php text eol=lf
*.bat text eol=crlf
VERSION text eol=lf

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
[Tt]humbs.db
[Dd]esktop.ini
.DS_Store
.vscode/
.vs/
.idea/
docs/html/
.phpdoc*
.phpunit*
vendor/

30
LICENCE Normal file
View file

@ -0,0 +1,30 @@
Copyright (c) 2024, flashwave <me@flash.moe>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted (subject to the limitations in the disclaimer
below) provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# Aiwass
Aiwass is an RPC client and server library.
An Index compatible route handler is provided to make it really low effort to get a server going.
## Requirements and Dependencies
Aiwass currently targets **PHP 8.3** with the `msgpack` extension installed.
## Versioning
Aiwass versioning will follows the [Semantic Versioning specification v2.0.0](https://semver.org/spec/v2.0.0.html).
Changes to minimum required PHP version other major overhauls to Aiwass itself that break compatibility will be reasons for incrementing the major version.
Aiwass depends on Index, but its versioning depends on the minimum PHP version and should thus be fairly inconsequential.
Previous major versions may be supported for a time with backports depending on what projects of mine still target older versions of PHP.
The version is stored in the root of the repository in a file called `VERSION` and can be read out within Aiwass using `Aiwass\Aiwass::version()`.
## Contribution
By submitting code for inclusion in the main Aiwass source tree you agree to transfer ownership of the code to the project owner.
The contributor will still be attributed for the contributed code, unless they ask for this attribution to be removed.
This is to avoid intellectual property rights traps and drama that could lead to blackmail situations.
If you do not agree with these terms, you are free to fork off.
## Licencing
Aiwass is available under the BSD 3-Clause Clear License, a full version of which is enclosed in the LICENCE file.

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0-dev

29
composer.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "flashwave/aiwass",
"description": "Shared HTTP RPC client/server library.",
"type": "library",
"homepage": "https://railgun.sh/aiwass",
"license": "bsd-3-clause-clear",
"require": {
"php": ">=8.3",
"ext-msgpack": ">=2.2",
"flashwave/index": "^0.2408.40014"
},
"require-dev": {
"phpunit/phpunit": "^11.2",
"phpstan/phpstan": "^1.11"
},
"authors": [
{
"name": "flashwave",
"email": "packagist@flash.moe",
"homepage": "https://flash.moe",
"role": "mom"
}
],
"autoload": {
"psr-4": {
"Aiwass\\": "src"
}
}
}

1755
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

35
phpdoc.xml Normal file
View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" ?>
<phpdocumentor configVersion="3.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://www.phpdoc.org"
xsi:noNamespaceSchemaLocation="data/xsd/phpdoc.xsd">
<title>Aiwass Documentation</title>
<template name="default"/>
<paths>
<output>docs/html</output>
</paths>
<version number="0.1">
<folder>latest</folder>
<api format="php">
<output>api</output>
<visibility>public</visibility>
<default-package-name>Aiwass</default-package-name>
<source dsn=".">
<path>src</path>
</source>
<extensions>
<extension>php</extension>
</extensions>
<ignore hidden="true" symlinks="true">
<path>tests/**/*</path>
</ignore>
<ignore-tags>
<ignore-tag>template</ignore-tag>
<ignore-tag>template-extends</ignore-tag>
<ignore-tag>template-implements</ignore-tag>
<ignore-tag>extends</ignore-tag>
<ignore-tag>implements</ignore-tag>
</ignore-tags>
</api>
</version>
</phpdocumentor>

7
phpstan.neon Normal file
View file

@ -0,0 +1,7 @@
parameters:
level: 9
checkUninitializedProperties: true
checkImplicitMixed: true
checkBenevolentUnionTypes: true
paths:
- src

23
phpunit.xml Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.2/phpunit.xsd"
colors="true"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true"
cacheDirectory=".phpunit.cache"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true">
<testsuites>
<testsuite name="default">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
<coverage/>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

34
src/Aiwass.php Normal file
View file

@ -0,0 +1,34 @@
<?php
// Aiwass.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
/**
* Provides information about the Aiwass library.
*/
final class Aiwass {
public const PATH_SOURCE = __DIR__;
public const PATH_ROOT = self::PATH_SOURCE . DIRECTORY_SEPARATOR . '..';
public const PATH_VERSION = self::PATH_ROOT . DIRECTORY_SEPARATOR . 'VERSION';
/**
* Gets the current version of the Aiwass library.
*
* Reads the VERSION file in the root of the Aiwass directory.
* Returns 0.0.0 if reading the file failed for any reason.
*
* @return string Current version string.
*/
public static function version(): string {
if(!is_file(self::PATH_VERSION))
return '0.0.0';
$version = file_get_contents(self::PATH_VERSION);
if($version === false)
return '0.0.0';
return trim($version);
}
}

31
src/AiwassMsgPack.php Normal file
View file

@ -0,0 +1,31 @@
<?php
// AiwassMsgPack.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
use MessagePack;
/**
* Internal container for MessagePack class.
*
* @internal
*/
final class AiwassMsgPack {
private static MessagePack $msgpack;
public static function encode(mixed $value): string {
$value = self::$msgpack->pack($value);
return is_string($value) ? $value : '';
}
public static function decode(string $value): mixed {
return @self::$msgpack->unpack($value, null);
}
public static function init(): void {
self::$msgpack = new MessagePack(false);
}
}
AiwassMsgPack::init();

101
src/CurlHttpRequest.php Normal file
View file

@ -0,0 +1,101 @@
<?php
// CurlHttpRequest.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
use CurlHandle;
use RuntimeException;
/**
* Provides a cURL based HTTP request implementation.
*/
class CurlHttpRequest implements IHttpRequest {
private CurlHandle $handle;
private bool $isPost = false;
private ?string $url = null;
/** @var string[] */
private array $headers = [];
private string $paramString = '';
public function __construct() {
$this->handle = curl_init();
curl_setopt_array($this->handle, [
CURLOPT_AUTOREFERER => false,
CURLOPT_FAILONERROR => false,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADER => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TCP_FASTOPEN => true,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_MAXREDIRS => 2,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => 5,
CURLOPT_USERAGENT => 'Aiwass',
]);
}
public function __destruct() {
$this->close();
}
public function close(): void {
curl_close($this->handle);
}
public function setPost(bool $state): void {
$this->isPost = $state;
curl_setopt($this->handle, CURLOPT_POST, $state);
}
public function setUrl(string $url): void {
$this->url = $url;
}
public function setHeader(string $name, string $value): void {
$this->headers[] = sprintf('%s: %s', $name, $value);
}
public function setParams(string $paramString): void {
$this->paramString = $paramString;
}
public function execute(): string {
$url = $this->url;
if($url === null)
throw new RuntimeException('no target url specified');
if($this->isPost) {
$this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
curl_setopt($this->handle, CURLOPT_POSTFIELDS, $this->paramString);
} else {
$url .= str_contains($url, '?') ? '&' : '?';
$url .= $this->paramString;
}
curl_setopt_array($this->handle, [
CURLOPT_HTTPHEADER => $this->headers,
CURLOPT_URL => $url,
]);
$response = curl_exec($this->handle);
if($response === false)
throw new RuntimeException(curl_error($this->handle));
$this->close();
if($response === true)
return '';
return $response;
}
/**
* Creates a new cURL HTTP Request instance.
*
* @return CurlHttpRequest cURL HTTP Request.
*/
public static function create(): CurlHttpRequest {
return new CurlHttpRequest;
}
}

View file

@ -0,0 +1,120 @@
<?php
// HmacVerificationProvider.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
use InvalidArgumentException;
use Index\UriBase64;
/**
* Implements an HMAC verification provider.
*/
class HmacVerificationProvider implements IVerificationProvider {
/**
* Default list of allowed algorithms.
*
* @var string[]
*/
public const DEFAULT_ALGOS = ['sha256'];
/**
* Amount of time that is added and subtracted from a user timestamp to check if its still valid.
*
* Meaning that if this value is 30, the timestamp the user provided will be considered valid if it is within 30 seconds into the past and 30 seconds into the future.
*
* @var int
*/
public const DEFAULT_TIME_VALID_PLUS_MINUS_SECONDS = 30;
/**
* @param callable(): string $getSecretKey A method that returns the secret key to use.
* @param string[] $algos List of algorithms to allow, first entry will be used by new tokens.
* @param int $timeValidPlusMinusSeconds Amount of time for which the token will be valid in the future and the past.
* @throws InvalidArgumentException If $getSecretKey is not a callable type.
* @throws InvalidArgumentException If $algos is an empty array.
* @throws InvalidArgumentException If $timeValidPlusMinusSeconds is less than 1.
*/
public function __construct(
private $getSecretKey,
private array $algos = self::DEFAULT_ALGOS,
private int $timeValidPlusMinusSeconds = self::DEFAULT_TIME_VALID_PLUS_MINUS_SECONDS
) {
if(!is_callable($getSecretKey))
throw new InvalidArgumentException('$getSecretKey must be a callable');
if(empty($algos))
throw new InvalidArgumentException('$algos must contain at least one entry');
if($timeValidPlusMinusSeconds < 1)
throw new InvalidArgumentException('$timeValidPlusMinusSeconds must be greater than zero');
}
public function sign(bool $isProcedure, string $action, string $paramString): string {
return $this->signWith($this->algos[0], $isProcedure, $action, $paramString);
}
private function signWith(string $algo, bool $isProcedure, string $action, string $paramString): string {
$time = time();
$hash = $this->createHash($algo, $time, $isProcedure, $action, $paramString);
return UriBase64::encode(AiwassMsgPack::encode([
'a' => $algo,
't' => $time,
'h' => $hash,
]));
}
private function createHash(
string $algo,
int $timeStamp,
bool $isProcedure,
string $action,
string $paramString
): string {
$data = sprintf(
'^<=>%d<=>%s<=>%s<=>%s<=>$',
$timeStamp,
$isProcedure ? 'p' : 'q',
$action,
$paramString
);
return hash_hmac($algo, $data, ($this->getSecretKey)(), true);
}
/**
* Checks if a given hashing algorithm is allowed by this verification provider.
*
* @param string $algo Algorithm name.
* @return bool true if it is supported.
*/
public function isValidAlgo(string $algo): bool {
return in_array($algo, $this->algos);
}
public function verify(string $userToken, bool $isProcedure, string $action, string $paramString): bool {
$userToken = UriBase64::decode($userToken);
if($userToken === false)
return false;
$userTokenInfo = AiwassMsgPack::decode($userToken);
if(!is_array($userTokenInfo))
return false;
$userAlgo = $userTokenInfo['a'] ?? null;
if(!is_string($userAlgo) || !$this->isValidAlgo($userAlgo))
return false;
$userTime = $userTokenInfo['t'] ?? null;
$currentTime = time();
if(!is_int($userTime) || $userTime < ($currentTime - $this->timeValidPlusMinusSeconds) || $userTime > ($currentTime + $this->timeValidPlusMinusSeconds))
return false;
$userHash = $userTokenInfo['h'] ?? null;
if(!is_string($userHash))
return false;
$realHash = $this->createHash($userAlgo, $userTime, $isProcedure, $action, $paramString);
return hash_equals($realHash, $userHash);
}
}

51
src/IHttpRequest.php Normal file
View file

@ -0,0 +1,51 @@
<?php
// IHttpRequest.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
use RuntimeException;
use Index\ICloseable;
/**
* Provides a common interface for making HTTP requests.
*/
interface IHttpRequest extends ICloseable {
/**
* Sets whether this is a POST request or a GET request.
*
* @param bool $state true if POST, false if GET.
*/
function setPost(bool $state): void;
/**
* Sets the target URL for this request.
*
* @param string $url Target URL.
*/
function setUrl(string $url): void;
/**
* Sets a request header.
*
* @param string $name Header name.
* @param string $value Header value.
*/
function setHeader(string $name, string $value): void;
/**
* Sets query or body params. Appended to the URL if GET, put in the body if POST.
*
* @param string $paramString Parameters.
*/
function setParams(string $paramString): void;
/**
* Execute the request.
*
* @throws RuntimeException If anything went wrong.
* @return string Response body if successful.
*/
function execute(): string;
}

View file

@ -0,0 +1,41 @@
<?php
// IVerificationProvider.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
/**
* Provides a common interface for verification providers for the route handler.
*/
interface IVerificationProvider {
/**
* Creates a signature.
*
* @param bool $isProcedure true if the request is a query or false if it is a procedure call.
* @param string $action Name of the action.
* @param string $paramString Query string value or body string value.
* @return string A token string for the given arguments.
*/
function sign(
bool $isProcedure,
string $action,
string $paramString
): string;
/**
* Verifies a signature.
*
* @param string $userToken A token provided by the user.
* @param bool $isProcedure true if the request is a query or false if it is a procedure call.
* @param string $action Name of the action.
* @param string $paramString Query string value or body string value.
* @return bool true if the token verified successfully.
*/
function verify(
string $userToken,
bool $isProcedure,
string $action,
string $paramString
): bool;
}

58
src/RpcActionInfo.php Normal file
View file

@ -0,0 +1,58 @@
<?php
// RpcActionInfo.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
use Closure;
use InvalidArgumentException;
/**
* Provides information about an RPC action.
*/
final class RpcActionInfo {
/**
* @param bool $isProcedure Whether this action is a procedure (true) or a query (false).
* @param string $name Name of the action.
* @param callable $handler Handler for the action.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
public function __construct(
private bool $isProcedure,
private string $name,
private $handler
) {
// format of name should be checked using the same regex as in RpcRouteHandler::handleAction
if(!is_callable($handler))
throw new InvalidArgumentException('$handler must be a callable');
}
/**
* Returns whether the action is a procedure.
*
* @return bool true if the action is a procedure, false if it is a query.
*/
public function isProcedure(): bool {
return $this->isProcedure;
}
/**
* Returns the name of this action.
*
* @return string Name of the action.
*/
public function getName(): string {
return $this->name;
}
/**
* Returns the handler for this action.
*
* @return Closure Handler for this action.
*/
public function getHandler(): Closure {
return Closure::fromCallable($this->handler);
}
}

61
src/RpcClient.php Normal file
View file

@ -0,0 +1,61 @@
<?php
// RpcClient.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
use InvalidArgumentException;
/**
* Implemens an RPC client.
*/
class RpcClient {
/**
* @param string $url Base RPC url, up to the /_aiwass part.
* @param callable(): IHttpRequest $createRequest Creates a HTTP request.
* @param IVerificationProvider $verify A verification provider.
*/
public function __construct(
private string $url,
private $createRequest,
private IVerificationProvider $verify
) {
if(!is_callable($createRequest))
throw new InvalidArgumentException('$createRequest must be a callable');
}
/** @param array<string, mixed> $params */
private function callAction(bool $isProcedure, string $action, array $params): mixed {
$params = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$request = ($this->createRequest)();
$request->setPost($isProcedure);
$request->setUrl(sprintf('%s/_aiwass/%s', $this->url, $action));
$request->setHeader('X-Aiwass-Verify', $this->verify->sign($isProcedure, $action, $params));
$request->setParams($params);
return AiwassMsgPack::decode($request->execute());
}
/**
* Makes an RPC query.
*
* @param string $action Name of the action.
* @param array<string, mixed> $params Parameters to query with.
* @return mixed Result of the query.
*/
public function query(string $action, array $params): mixed {
return $this->callAction(false, $action, $params);
}
/**
* Makes an RPC procedure call.
*
* @param string $action Name of the action.
* @param array<string, mixed> $params Parameters to query with.
* @return mixed Result of the procedure call.
*/
public function procedure(string $action, array $params): mixed {
return $this->callAction(true, $action, $params);
}
}

109
src/RpcRouteHandler.php Normal file
View file

@ -0,0 +1,109 @@
<?php
// RpcRouteHandler.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
use Exception;
use ReflectionFunction;
use ReflectionIntersectionType;
use ReflectionNamedType;
use ReflectionUnionType;
use Index\Http\{HttpResponseBuilder,HttpRequest};
use Index\Http\Content\FormContent;
use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler};
/**
* Provides a router implementation for an Aiwass RPC server.
*/
class RpcRouteHandler extends RouteHandler {
/**
* @param RpcServer $server An RPC server instance.
*/
public function __construct(
private RpcServer $server
) {}
/**
* Handles an action request.
*
* @param HttpResponseBuilder $response Response that it being built.
* @param HttpRequest $request Request that is being handled.
* @param string $action Name of the action specified in the URL.
* @return int|array{error: string, message?: string}|mixed Response to the request.
*/
#[HttpGet('/_aiwass/([A-Za-z0-9\-_\.:]+)')]
#[HttpPost('/_aiwass/([A-Za-z0-9\-_\.:]+)')]
public function handleAction(HttpResponseBuilder $response, HttpRequest $request, string $action) {
if($action === '')
return 404;
if($request->getMethod() === 'POST') {
$content = $request->getContent();
if(!($content instanceof FormContent))
return 400;
$expectProcedure = true;
$paramString = $content->getParamString();
$params = $content->getParams();
} elseif($request->getMethod() === 'GET') {
$expectProcedure = false;
$paramString = $request->getParamString();
$params = $request->getParams();
} else {
$response->setHeader('Allow', 'GET, POST');
return 405;
}
$response->setContentType('application/vnd.msgpack');
$userToken = (string)$request->getHeaderLine('X-Aiwass-Verify');
if($userToken === '') {
$response->setStatusCode(403);
return AiwassMsgPack::encode(['error' => 'aiwass:verify', 'message' => 'X-Aiwass-Verify header is missing.']);
}
$actInfo = $this->server->getActionInfo($action);
if($actInfo === null) {
$response->setStatusCode(404);
return AiwassMsgPack::encode(['error' => 'aiwass:unknown', 'message' => 'No action with that name exists.']);
}
if($actInfo->isProcedure() !== $expectProcedure) {
$response->setStatusCode(405);
$response->setHeader('Allow', $actInfo->isProcedure() ? 'POST' : 'GET');
return AiwassMsgPack::encode(['error' => 'aiwass:method', 'message' => 'This action cannot be called using this request method.']);
}
if(!$this->server->getVerificationProvider()->verify($userToken, $expectProcedure, $action, $paramString))
return AiwassMsgPack::encode(['error' => 'aiwass:verify', 'message' => 'Request token verification failed.']);
$handlerInfo = new ReflectionFunction($actInfo->getHandler());
if($handlerInfo->isVariadic())
return AiwassMsgPack::encode(['error' => 'aiwass:variadic', 'message' => 'Handler was declared as a variadic method and is thus impossible to be called.']);
$handlerArgs = [];
$handlerParams = $handlerInfo->getParameters();
foreach($handlerParams as $paramInfo) {
$paramName = $paramInfo->getName();
if(!array_key_exists($paramName, $params)) {
if($paramInfo->isOptional())
continue;
if(!$paramInfo->allowsNull())
return AiwassMsgPack::encode(['error' => 'aiwass:param:required', 'message' => 'A required parameter is missing.', 'param' => $paramName]);
}
$handlerArgs[$paramName] = $params[$paramName] ?? null;
}
if(count($handlerArgs) !== count($params)) {
$response->setStatusCode(400);
return AiwassMsgPack::encode(['error' => 'aiwass:params', 'message' => 'Unsupported arguments were specified.']);
}
return AiwassMsgPack::encode($handlerInfo->invokeArgs($handlerArgs));
}
}

102
src/RpcServer.php Normal file
View file

@ -0,0 +1,102 @@
<?php
// RpcServer.php
// Created: 2024-08-13
// Updated: 2024-08-13
namespace Aiwass;
use InvalidArgumentException;
use RuntimeException;
/**
* Implements an RPC server.
*/
class RpcServer {
/** @var array<string, RpcActionInfo> */
private array $actions = [];
/**
* @param IVerificationProvider $verify A verification provider.
*/
public function __construct(
private IVerificationProvider $verify
) {}
/**
* Retrieves this verification provider for this server.
*
* @return IVerificationProvider Verification provider implementation.
*/
public function getVerificationProvider(): IVerificationProvider {
return $this->verify;
}
/**
* Creates an RPC route handler.
*
* @return RpcRouteHandler RPC route handler.
*/
public function createRouteHandler(): RpcRouteHandler {
return new RpcRouteHandler($this);
}
/**
* Registers an action.
*
* @param bool $isProcedure true if the action is a procedure (HTTP POST), false if it is a query (HTTP GET).
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
public function registerAction(bool $isProcedure, string $name, $handler): void {
if(array_key_exists($name, $this->actions))
throw new RuntimeException('an action with that name has already been registered');
$this->actions[$name] = new RpcActionInfo($isProcedure, $name, $handler);
}
/**
* Registers a query action (HTTP GET).
*
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
public function registerQueryAction(string $name, $handler): void {
$this->registerAction(false, $name, $handler);
}
/**
* Registers a procedure action (HTTP POST).
*
* @param string $name Unique name of the action.
* @param callable $handler Handler for the action.
* @throws RuntimeException If a handler with the same name is already registered.
* @throws InvalidArgumentException If $handler is not a callable type.
*/
public function registerProcedureAction(string $name, $handler): void {
$this->registerAction(true, $name, $handler);
}
/**
* Retrieves information about an action.
*
* @param string $name Name of the action.
* @return ?RpcActionInfo An object containing information about the action, or null if it does not exist.
*/
public function getActionInfo(string $name): ?RpcActionInfo {
return $this->actions[$name] ?? null;
}
/**
* Creates an RPC server with a Hmac verification provider.
*
* @param callable(): string $getSecretKey A method that returns the secret key to use.
* @return RpcServer The RPC server.
*/
public static function createHmac($getSecretKey): RpcServer {
return new RpcServer(new HmacVerificationProvider($getSecretKey));
}
}

30
tools/create-tag Executable file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env php
<?php
$path = (function($path) {
if(!str_starts_with($path, '/'))
die('Cannot be bothered to support non-UNIX style paths, sorry!' . PHP_EOL);
while($path !== '/') {
$vPath = $path . DIRECTORY_SEPARATOR . 'VERSION';
if(is_file($vPath))
return $vPath;
$path = dirname($path);
}
})(__DIR__);
$version = file_get_contents($path);
if($version === false)
die('Failed to read VERSION file.' . PHP_EOL);
$version = trim($version);
$workingDir = getcwd();
try {
chdir(dirname($path));
echo shell_exec(sprintf('git tag v%s', $version));
echo shell_exec(sprintf('git push origin v%s', $version));
} finally {
chdir($workingDir);
}
echo $version . PHP_EOL;

8
tools/precommit.sh Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env bash
pushd .
cd $(dirname "$0")
./update-headers
popd

173
tools/update-headers Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env php
<?php
// the point of index was so that i wouldn't have to copy things between projects
// here i am copying things from index
date_default_timezone_set('utc');
function git_changes(): array {
$files = [];
$dir = getcwd();
try {
chdir(__DIR__ . '/..');
$output = explode("\n", trim(shell_exec('git status --short --porcelain=v1 --untracked-files=all --no-column')));
foreach($output as $line) {
$line = trim($line);
$file = realpath(explode(' ', $line)[1]);
$files[] = $file;
}
} finally {
chdir($dir);
}
return $files;
}
function collect_files(string $directory): array {
$files = [];
$dir = glob(realpath($directory) . DIRECTORY_SEPARATOR . '*');
foreach($dir as $file) {
if(is_dir($file)) {
$files = array_merge($files, collect_files($file));
continue;
}
$files[] = $file;
}
return $files;
}
echo 'Indexing changed files according to git...' . PHP_EOL;
$changed = git_changes();
echo 'Collecting files...' . PHP_EOL;
$sources = collect_files(__DIR__ . '/../src');
$tests = collect_files(__DIR__ . '/../tests');
$files = array_merge($sources, $tests);
$topDir = dirname(__DIR__) . DIRECTORY_SEPARATOR;
$now = date('Y-m-d');
foreach($files as $file) {
echo 'Scanning ' . str_replace($topDir, '', $file) . '...' . PHP_EOL;
try {
$handle = fopen($file, 'rb');
$checkPHP = trim(fgets($handle)) === '<?php';
if(!$checkPHP) {
echo 'File is not PHP.' . PHP_EOL;
continue;
}
$headerLines = [];
$expectLine = '// ' . basename($file);
$nameLine = trim(fgets($handle));
if($nameLine !== $expectLine) {
echo ' File name is missing or invalid, queuing update...' . PHP_EOL;
$headerLines['name'] = $expectLine;
}
$createdPrefix = '// Created: ';
$createdLine = trim(fgets($handle));
if(strpos($createdLine, $createdPrefix) !== 0) {
echo ' Creation date is missing, queuing update...' . PHP_EOL;
$headerLines['created'] = $createdPrefix . $now;
}
$updatedPrefix = '// Updated: ';
$updatedLine = trim(fgets($handle));
$updatedDate = substr($updatedLine, strlen($updatedPrefix));
if(strpos($updatedLine, $updatedPrefix) !== 0 || (in_array($file, $changed) && $updatedDate !== $now)) {
echo ' Updated date is inaccurate, queuing update...' . PHP_EOL;
$headerLines['updated'] = $updatedPrefix . $now;
}
$blankLine = trim(fgets($handle));
if(!empty($blankLine)) {
echo ' Trailing newline missing, queuing update...' . PHP_EOL;
$headerLines['blank'] = '';
}
if(!empty($headerLines)) {
fclose($handle);
try {
$tmpName = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ndx-uh-' . bin2hex(random_bytes(8)) . '.tmp';
copy($file, $tmpName);
$read = fopen($tmpName, 'rb');
$handle = fopen($file, 'wb');
fwrite($handle, fgets($read));
$insertAfter = [];
if(empty($headerLines['name']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['name'] . "\n");
}
if(empty($headerLines['created']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// Created: ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['created'] . "\n");
}
if(empty($headerLines['updated']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// Updated: ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['updated'] . "\n");
}
if(!isset($headerLines['blank']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(!empty($line)) {
$insertAfter[] = $line;
fwrite($handle, "\n");
}
}
foreach($insertAfter as $line)
fwrite($handle, $line);
while(($line = fgets($read)) !== false)
fwrite($handle, $line);
} finally {
if(is_resource($read))
fclose($read);
if(is_file($tmpName))
unlink($tmpName);
}
}
} finally {
fclose($handle);
}
}