diff --git a/src/Socialbox/Classes/CacheLayer/RedisCacheLayer.php b/src/Socialbox/Classes/CacheLayer/RedisCacheLayer.php new file mode 100644 index 0000000..1fcb787 --- /dev/null +++ b/src/Socialbox/Classes/CacheLayer/RedisCacheLayer.php @@ -0,0 +1,118 @@ +redis = new Redis(); + + try + { + $this->redis->connect($host, $port); + if ($password !== null) + { + $this->redis->auth($password); + } + } + catch (RedisException $e) + { + throw new RuntimeException('Failed to connect to the Redis server.', 0, $e); + } + } + + /** + * @inheritDoc + */ + public function set(string $key, mixed $value, int $ttl = 0): bool + { + try + { + return $this->redis->set($key, $value, $ttl); + } + catch (RedisException $e) + { + throw new RuntimeException('Failed to set the value in the Redis cache.', 0, $e); + } + } + + /** + * @inheritDoc + */ + public function get(string $key): mixed + { + try + { + return $this->redis->get($key); + } + catch (RedisException $e) + { + throw new RuntimeException('Failed to get the value from the Redis cache.', 0, $e); + } + } + + /** + * @inheritDoc + */ + public function delete(string $key): bool + { + try + { + return $this->redis->del($key) > 0; + } + catch (RedisException $e) + { + throw new RuntimeException('Failed to delete the value from the Redis cache.', 0, $e); + } + } + + /** + * @inheritDoc + */ + public function exists(string $key): bool + { + try + { + return $this->redis->exists($key); + } + catch (RedisException $e) + { + throw new RuntimeException('Failed to check if the key exists in the Redis cache.', 0, $e); + } + } + + /** + * @inheritDoc + */ + public function clear(): bool + { + try + { + return $this->redis->flushAll(); + } + catch (RedisException $e) + { + throw new RuntimeException('Failed to clear the Redis cache.', 0, $e); + } + } +} \ No newline at end of file diff --git a/src/Socialbox/Classes/RpcHandler.php b/src/Socialbox/Classes/RpcHandler.php new file mode 100644 index 0000000..22c64bf --- /dev/null +++ b/src/Socialbox/Classes/RpcHandler.php @@ -0,0 +1,196 @@ +getMessage(), 400, $e); + } + + $clientRequest = new ClientRequest($headers, self::getRpcRequests(), self::getRequestHash()); + + // Verify the session & request signature + if($clientRequest->getSessionUuid() !== null) + { + // If no signature is provided, it must be required if the client is providing a Session UUID + if($clientRequest->getSignature() === null) + { + throw new RpcException(sprintf('Unauthorized request, signature required for session based requests', StandardHeaders::SIGNATURE->value), 401); + } + + try + { + if(!SessionManager::sessionExists($clientRequest->getSessionUuid())) + { + throw new RpcException('Session UUID not found', 404); + } + + $session = SessionManager::getSession($clientRequest->getSessionUuid()); + + // Verify the signature of the request + if(!Cryptography::verifyContent($clientRequest->getHash(), $clientRequest->getSignature(), $session->getPublicKey())) + { + throw new RpcException('Request signature check failed', 400); + } + } + catch(CryptographyException $e) + { + throw new RpcException('Request signature check failed (Cryptography Error)', 400, $e); + } + catch(DatabaseOperationException $e) + { + throw new RpcException('Failed to verify session', 500, $e); + } + } + + return $clientRequest; + } + + /** + * Returns the request hash by hashing the request body using SHA256 + * + * @return string Returns the request hash in SHA256 representation + */ + private static function getRequestHash(): string + { + return hash('sha1', file_get_contents('php://input')); + } + + /** + * Handles a POST request, returning an array of RpcRequest objects + * expects a JSON encoded body with either a single RpcRequest object or an array of RpcRequest objects + * + * @return RpcRequest[] The parsed RpcRequest objects + * @throws RpcException Thrown if the request is invalid + */ + private static function getRpcRequests(): array + { + try + { + // Decode the request body + $body = Utilities::jsonDecode(file_get_contents('php://input')); + } + catch(InvalidArgumentException $e) + { + throw new RpcException("Invalid JSON in request body: " . $e->getMessage(), 400, $e); + } + + if(isset($body['method'])) + { + // If it only contains a method, we assume it's a single request + return [self::parseRequest($body)]; + } + + // Otherwise, we assume it's an array of requests + return array_map(fn($request) => self::parseRequest($request), $body); + } + + /** + * Parses the raw request data into an RpcRequest object + * + * @param array $data The raw request data + * @return RpcRequest The parsed RpcRequest object + * @throws RpcException If the request is invalid + */ + private static function parseRequest(array $data): RpcRequest + { + if(!isset($data['method'])) + { + throw new RpcException("Missing 'method' key in request", 400); + } + + if(isset($data['id'])) + { + if(!is_string($data['id'])) + { + throw new RpcException("Invalid 'id' key in request: Expected string", 400); + } + + if(strlen($data['id']) === 0) + { + throw new RpcException("Invalid 'id' key in request: Expected non-empty string", 400); + } + + if(strlen($data['id']) > 8) + { + throw new RpcException("Invalid 'id' key in request: Expected string of length <= 8", 400); + } + } + + if(isset($data['parameters'])) + { + if(!is_array($data['parameters'])) + { + throw new RpcException("Invalid 'parameters' key in request: Expected array", 400); + } + } + + return new RpcRequest($data['method'], $data['id'] ?? null, $data['parameters'] ?? null); + } +} \ No newline at end of file diff --git a/src/Socialbox/Classes/StandardMethods/Ping.php b/src/Socialbox/Classes/StandardMethods/Ping.php new file mode 100644 index 0000000..c0b8007 --- /dev/null +++ b/src/Socialbox/Classes/StandardMethods/Ping.php @@ -0,0 +1,19 @@ +produceResponse(true); + } +} \ No newline at end of file diff --git a/src/Socialbox/Classes/Utilities.php b/src/Socialbox/Classes/Utilities.php new file mode 100644 index 0000000..2dde003 --- /dev/null +++ b/src/Socialbox/Classes/Utilities.php @@ -0,0 +1,108 @@ + new InvalidArgumentException("JSON decoding failed: Maximum stack depth exceeded"), + JSON_ERROR_STATE_MISMATCH => new InvalidArgumentException("JSON decoding failed: Underflow or the modes mismatch"), + JSON_ERROR_CTRL_CHAR => new InvalidArgumentException("JSON decoding failed: Unexpected control character found"), + JSON_ERROR_SYNTAX => new InvalidArgumentException("JSON decoding failed: Syntax error, malformed JSON"), + JSON_ERROR_UTF8 => new InvalidArgumentException("JSON decoding failed: Malformed UTF-8 characters, possibly incorrectly encoded"), + default => new InvalidArgumentException("JSON decoding failed: Unknown error"), + }; + } + + return $decoded; + } + + /** + * Encodes the given data in Base64. + * + * @param string $data The data to be encoded. + * @return string The Base64 encoded string. + * @throws InvalidArgumentException if the encoding fails. + */ + public static function base64encode(string $data): string + { + $encoded = base64_encode($data); + + if (!$encoded) + { + throw new InvalidArgumentException('Failed to encode data in Base64'); + } + + return $encoded; + } + + /** + * Decodes a Base64 encoded string. + * + * @param string $data The Base64 encoded data to be decoded. + * @return string The decoded data. + * @throws InvalidArgumentException If decoding fails. + */ + public static function base64decode(string $data): string + { + $decoded = base64_decode($data, true); + + if ($decoded === false) + { + throw new InvalidArgumentException('Failed to decode data from Base64'); + } + + return $decoded; + } + + /** + * Returns the request headers as an associative array + * + * @return array + */ + public static function getRequestHeaders(): array + { + // Check if function getallheaders() exists + if (function_exists('getallheaders')) + { + $headers = getallheaders(); + } + else + { + // Fallback for servers where getallheaders() is not available + $headers = []; + foreach ($_SERVER as $key => $value) + { + if (str_starts_with($key, 'HTTP_')) + { + // Convert header names to the normal HTTP format + $headers[str_replace('_', '-', strtolower(substr($key, 5)))] = $value; + } + } + } + + if($headers === false) + { + throw new RuntimeException('Failed to get request headers'); + } + + return $headers; + } +} \ No newline at end of file diff --git a/src/Socialbox/Classes/Validator.php b/src/Socialbox/Classes/Validator.php new file mode 100644 index 0000000..06e9de3 --- /dev/null +++ b/src/Socialbox/Classes/Validator.php @@ -0,0 +1,19 @@ +toRfc4122(); + + try + { + $statement = Database::getConnection()->prepare("INSERT INTO sessions (uuid, public_key) VALUES (?, ?)"); + $statement->bindParam(1, $uuid); + $statement->bindParam(2, $publicKey); + $statement->execute(); + } + catch(PDOException $e) + { + throw new DatabaseOperationException('Failed to create a session on the database', $e); + } + + return $uuid; + } + + /** + * Checks if a session with the given UUID exists in the database. + * + * @param string $uuid The UUID of the session to check. + * @return bool True if the session exists, false otherwise. + * @throws DatabaseOperationException If there is an error executing the database query. + */ + public static function sessionExists(string $uuid): bool + { + try + { + $statement = Database::getConnection()->prepare("SELECT COUNT(*) FROM sessions WHERE uuid=?"); + $statement->bindParam(1, $uuid); + $statement->execute(); + $result = $statement->fetchColumn(); + + return $result > 0; + } + catch(PDOException $e) + { + throw new DatabaseOperationException('Failed to check if the session exists', $e); + } + } + + /** + * Retrieves a session record by its unique identifier. + * + * @param string $uuid The unique identifier of the session. + * @return SessionRecord The session record corresponding to the given UUID. + * @throws DatabaseOperationException If the session record cannot be found or if there is an error during retrieval. + */ + public static function getSession(string $uuid): SessionRecord + { + try + { + $statement = Database::getConnection()->prepare("SELECT * FROM sessions WHERE uuid=?"); + $statement->bindParam(1, $uuid); + $statement->execute(); + $data = $statement->fetch(PDO::FETCH_ASSOC); + + if ($data === false) + { + throw new DatabaseOperationException(sprintf('Session record %s not found', $uuid)); + } + + // Convert the timestamp fields to DateTime objects + $data['created'] = new DateTime($data['created']); + $data['last_request'] = new DateTime($data['last_request']); + + return SessionRecord::fromArray($data); + } + catch (PDOException | DateMalformedStringException $e) + { + throw new DatabaseOperationException(sprintf('Failed to retrieve session record %s', $uuid), $e); + } + } + + /** + * Update the authenticated peer associated with the given session UUID. + * + * @param string $uuid The UUID of the session to update. + * @return void + */ + public static function updateAuthenticatedPeer(string $uuid): void + { + try + { + $statement = Database::getConnection()->prepare("UPDATE sessions SET authenticated_peer_uuid=? WHERE uuid=?"); + $statement->bindParam(1, $uuid); + $statement->bindParam(2, $uuid); + $statement->execute(); + } + catch (PDOException $e) + { + throw new DatabaseOperationException('Failed to update authenticated peer', $e); + } + } + + /** + * Updates the last request timestamp for a given session by its UUID. + * + * @param string $uuid The UUID of the session to be updated. + * @return void + */ + public static function updateLastRequest(string $uuid): void + { + try + { + $formattedTime = (new DateTime('@' . time()))->format('Y-m-d H:i:s'); + $statement = Database::getConnection()->prepare("UPDATE sessions SET last_request=? WHERE uuid=?"); + $statement->bindValue(1, $formattedTime, PDO::PARAM_STR); + $statement->bindParam(2, $uuid); + $statement->execute(); + } + catch (PDOException | DateMalformedStringException $e) + { + throw new DatabaseOperationException('Failed to update last request', $e); + } + } + + /** + * Updates the state of a session given its UUID. + * + * @param string $uuid The unique identifier of the session to update. + * @param SessionState $state The new state to be set for the session. + * @return void No return value. + */ + public static function updateState(string $uuid, SessionState $state): void + { + try + { + $state_value = $state->value; + $statement = Database::getConnection()->prepare('UPDATE sessions SET state=? WHERE uuid=?'); + $statement->bindParam(1, $state_value); + $statement->bindParam(2, $uuid); + } + catch(PDOException $e) + { + throw new DatabaseOperationException('Failed to update session state', $e); + } + } + } \ No newline at end of file diff --git a/src/Socialbox/Objects/PeerAddress.php b/src/Socialbox/Objects/PeerAddress.php new file mode 100644 index 0000000..42af2be --- /dev/null +++ b/src/Socialbox/Objects/PeerAddress.php @@ -0,0 +1,96 @@ +username = $username; + $this->domain = $domain; + } + + /** + * Constructs a PeerAddress from a full peer address (eg; john@example.com) + * + * @param string $address The full address of the peer + * @return PeerAddress The constructed PeerAddress object + */ + public static function fromAddress(string $address): PeerAddress + { + if(!Validator::validatePeerAddress($address)) + { + throw new InvalidArgumentException("Invalid peer address: $address"); + } + + $parts = explode('@', $address); + return new PeerAddress($parts[0], $parts[1]); + } + + /** + * Returns the username of the peer + * + * @return string + */ + public function getUsername(): string + { + return $this->username; + } + + /** + * Returns the domain of the peer + * + * @return string + */ + public function getDomain(): string + { + return $this->domain; + } + + /** + * Returns whether the peer is the host + * + * @return bool True if the peer is the host, false otherwise + */ + public function isHost(): bool + { + return $this->username === ReservedUsernames::HOST->value; + } + + /** + * Returns whether the peer requires authentication, for example, the anonymous user does not require authentication + * + * @return bool True if authentication is required, false otherwise + */ + public function authenticationRequired(): bool + { + return match($this->username) + { + ReservedUsernames::ANONYMOUS->value => false, + default => true + }; + } + + /** + * Returns the full address of the peer + * + * @return string The full address of the peer + */ + public function getAddress(): string + { + return sprintf("%s@%s", $this->username, $this->domain); + } +} \ No newline at end of file diff --git a/src/Socialbox/Objects/RpcError.php b/src/Socialbox/Objects/RpcError.php index 6867acd..d8a2d9c 100644 --- a/src/Socialbox/Objects/RpcError.php +++ b/src/Socialbox/Objects/RpcError.php @@ -2,26 +2,27 @@ namespace Socialbox\Objects; +use Socialbox\Enums\StandardError; use Socialbox\Interfaces\SerializableInterface; class RpcError implements SerializableInterface { private string $id; - private string $error; - private int $code; + private string $message; + private StandardError $code; /** * Constructs the RPC error object. * * @param string $id The ID of the RPC request - * @param string $error The error message - * @param int $code The error code + * @param StandardError $error The error code + * @param string $message The error message */ - public function __construct(string $id, string $error, int $code) + public function __construct(string $id, StandardError $error, string $message) { $this->id = $id; - $this->error = $error; - $this->code = $code; + $this->code = $error; + $this->message = $message; } /** @@ -39,17 +40,17 @@ class RpcError implements SerializableInterface * * @return string The error message. */ - public function getError(): string + public function getMessage(): string { - return $this->error; + return $this->message; } /** * Returns the error code. * - * @return int The error code. + * @return StandardError The error code. */ - public function getCode(): int + public function getCode(): StandardError { return $this->code; } @@ -63,8 +64,8 @@ class RpcError implements SerializableInterface { return [ 'id' => $this->id, - 'error' => $this->error, - 'code' => $this->code + 'error' => $this->message, + 'code' => $this->code->value ]; } @@ -76,6 +77,13 @@ class RpcError implements SerializableInterface */ public static function fromArray(array $data): RpcError { - return new RpcError($data['id'], $data['error'], $data['code']); + $errorCode = StandardError::tryFrom($data['code']); + + if($errorCode == null) + { + $errorCode = StandardError::UNKNOWN; + } + + return new RpcError($data['id'], $data['error'], $errorCode); } } \ No newline at end of file diff --git a/src/Socialbox/Objects/RpcRequest.php b/src/Socialbox/Objects/RpcRequest.php index 228cfaf..feb8f9f 100644 --- a/src/Socialbox/Objects/RpcRequest.php +++ b/src/Socialbox/Objects/RpcRequest.php @@ -2,6 +2,11 @@ namespace Socialbox\Objects; +use InvalidArgumentException; +use ncc\ThirdParty\nikic\PhpParser\Node\Expr\BinaryOp\BooleanOr; +use Socialbox\Enums\StandardError; +use Socialbox\Exceptions\RpcException; +use Socialbox\Exceptions\StandardException; use Socialbox\Interfaces\SerializableInterface; class RpcRequest implements SerializableInterface @@ -13,13 +18,15 @@ class RpcRequest implements SerializableInterface /** * Constructs the object from an array of data. * - * @param array $data The data to construct the object from. + * @param string $method The method of the request. + * @param string|null $id The ID of the request. + * @param array|null $parameters The parameters of the request. */ - public function __construct(array $data) + public function __construct(string $method, ?string $id, ?array $parameters) { - $this->id = $data['id'] ?? null; - $this->method = $data['method']; - $this->parameters = $data['parameters'] ?? null; + $this->method = $method; + $this->parameters = $parameters; + $this->id = $id; } /** @@ -45,13 +52,106 @@ class RpcRequest implements SerializableInterface /** * Returns the parameters of the request. * - * @return array|null The parameters of the request. + * @return array|null The parameters of the request, null if the request is a notification. */ public function getParameters(): ?array { return $this->parameters; } + /** + * Checks if the parameter exists within the RPC request + * + * @param string $parameter The parameter to check + * @return bool True if the parameter exists, False otherwise. + */ + public function containsParameter(string $parameter): bool + { + return isset($this->parameters[$parameter]); + } + + /** + * Returns the parameter value from the RPC request + * + * @param string $parameter The parameter name to get + * @return mixed The parameter value, null if the parameter value is null or not found. + */ + public function getParameter(string $parameter): mixed + { + if(!$this->containsParameter($parameter)) + { + return null; + } + + return $this->parameters[$parameter]; + } + + /** + * Produces a response based off the request, null if the request is a notification + * + * @param mixed|null $result + * @return RpcResponse|null + */ + public function produceResponse(mixed $result=null): ?RpcResponse + { + if($this->id == null) + { + return null; + } + + $valid = false; + if(is_array($result)) + { + $valid = true; + } + elseif($result instanceof SerializableInterface) + { + $valid = true; + } + elseif(is_null($result)) + { + $valid = true; + } + + if(!$valid) + { + throw new InvalidArgumentException('The \'$result\' property must either be array, null or SerializableInterface'); + } + + return new RpcResponse($this->id, $result); + } + + /** + * Produces an error response based off the request, null if the request is a notification + * + * @param StandardError $error + * @param string|null $message + * @return RpcError|null + */ + public function produceError(StandardError $error, ?string $message=null): ?RpcError + { + if($this->id == null) + { + return null; + } + + if($message == null) + { + $message = $error->getMessage(); + } + + return new RpcError($this->id, $error, $message); + } + + /** + * @param StandardException $e + * @return RpcError|null + */ + public function handleStandardException(StandardException $e): ?RpcError + { + return $this->produceError($e->getStandardError(), $e->getMessage()); + } + /** * Returns an array representation of the object. * @@ -74,6 +174,6 @@ class RpcRequest implements SerializableInterface */ public static function fromArray(array $data): RpcRequest { - return static($data); + return new RpcRequest($data['method'], $data['id'] ?? null, $data['parameters'] ?? null); } } \ No newline at end of file diff --git a/src/Socialbox/Objects/RpcResponse.php b/src/Socialbox/Objects/RpcResponse.php index 1192387..01ac789 100644 --- a/src/Socialbox/Objects/RpcResponse.php +++ b/src/Socialbox/Objects/RpcResponse.php @@ -7,15 +7,15 @@ use Socialbox\Interfaces\SerializableInterface; class RpcResponse implements SerializableInterface { private string $id; - private ?object $result; + private mixed $result; /** * Constructs the response object. * * @param string $id The ID of the response. - * @param object|null $result The result of the response. + * @param mixed|null $result The result of the response. */ - public function __construct(string $id, ?object $result) + public function __construct(string $id, mixed $result) { $this->id = $id; $this->result = $result; @@ -34,13 +34,37 @@ class RpcResponse implements SerializableInterface /** * Returns the result of the response. * - * @return object|null The result of the response. + * @return mixed|null The result of the response. */ - public function getResult(): ?object + public function getResult(): mixed { return $this->result; } + /** + * Converts the given data to an array. + * + * @param mixed $data The data to be converted. This can be an instance of SerializableInterface, an array, or a scalar value. + * @return mixed The converted data as an array if applicable, or the original data. + */ + private function convertToArray(mixed $data): mixed + { + // If the data is an instance of SerializableInterface, call toArray on it + if ($data instanceof SerializableInterface) + { + return $data->toArray(); + } + + // If the data is an array, recursively apply this method to each element + if (is_array($data)) + { + return array_map([$this, 'convertToArray'], $data); + } + + // Otherwise, return the data as-is (e.g., for scalar values) + return $data; + } + /** * Returns an array representation of the object. * @@ -50,7 +74,7 @@ class RpcResponse implements SerializableInterface { return [ 'id' => $this->id, - 'result' => $this->result->toArray() + 'result' => $this->convertToArray($this->result) ]; } diff --git a/src/Socialbox/Objects/SessionRecord.php b/src/Socialbox/Objects/SessionRecord.php new file mode 100644 index 0000000..5b9ee23 --- /dev/null +++ b/src/Socialbox/Objects/SessionRecord.php @@ -0,0 +1,82 @@ +uuid = $data['uuid']; + $this->authenticatedPeerUuid = $data['authenticated_peer_uuid'] ?? null; + $this->publicKey = $data['public_key']; + $this->created = $data['created']; + $this->lastRequest = $data['last_request']; + + if(SessionState::tryFrom($data['state']) == null) + { + $this->state = SessionState::CLOSED; + } + else + { + $this->state = SessionState::from($data['state']); + } + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getAuthenticatedPeerUuid(): ?string + { + return $this->authenticatedPeerUuid; + } + + public function getPublicKey(): string + { + return $this->publicKey; + } + + public function getState(): SessionState + { + return $this->state; + } + + public function getCreated(): int + { + return $this->created; + } + + public function getLastRequest(): int + { + return $this->lastRequest; + } + + public static function fromArray(array $data): object + { + return new self($data); + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'authenticated_peer_uuid' => $this->authenticatedPeerUuid, + 'public_key' => $this->publicKey, + 'state' => $this->state->value, + 'created' => $this->created, + 'last_request' => $this->lastRequest, + ]; + } +} \ No newline at end of file diff --git a/src/Socialbox/Socialbox.php b/src/Socialbox/Socialbox.php index 1c3a37c..967208f 100644 --- a/src/Socialbox/Socialbox.php +++ b/src/Socialbox/Socialbox.php @@ -1,8 +1,54 @@ getCode()); + print($e->getMessage()); + return; + } + $results = []; + foreach($clientRequest->getRequests() as $rpcRequest) + { + $method = StandardMethods::tryFrom($rpcRequest->getMethod()); + + if($method === false) + { + $response = $rpcRequest->produceError(StandardError::RPC_METHOD_NOT_FOUND, 'The requested method does not exist');; + if($response !== null) + { + $results[] = $response; + } + } + + $response = $method->execute($clientRequest, $rpcRequest); + if($response !== null) + { + $results[] = $response; + } + } + + if(count($results) > 0) + { + print(json_encode($results)); + return; + } + + http_response_code(204); + } } \ No newline at end of file