diff options
| -rw-r--r-- | matrix-specification/Data/Capabilities.php | 44 | ||||
| -rw-r--r-- | matrix-specification/Data/Capability/BooleanCapability.php | 18 | ||||
| -rw-r--r-- | matrix-specification/Data/Capability/ProfileFieldsCapability.php | 26 | ||||
| -rw-r--r-- | matrix-specification/Data/Capability/RoomVersionsCapability.php | 23 | ||||
| -rw-r--r-- | migrations/20250819.php | 37 | ||||
| -rw-r--r-- | src/App.php | 9 | ||||
| -rw-r--r-- | src/Controllers/Client/ClientController.php | 62 | ||||
| -rw-r--r-- | src/Controllers/Client/KeyController.php | 85 | ||||
| -rw-r--r-- | src/Controllers/Client/MediaController.php | 23 | ||||
| -rwxr-xr-x | src/Controllers/Client/RoomController.php | 45 | ||||
| -rwxr-xr-x | src/Controllers/Client/UserController.php | 52 | ||||
| -rw-r--r-- | src/Models/RoomEvent.php | 2 | ||||
| -rw-r--r-- | src/Models/User.php | 27 | ||||
| -rw-r--r-- | src/Router.php | 2 | ||||
| -rw-r--r-- | src/Support/Logger.php | 11 |
15 files changed, 439 insertions, 27 deletions
diff --git a/matrix-specification/Data/Capabilities.php b/matrix-specification/Data/Capabilities.php new file mode 100644 index 0000000..3aa54a7 --- /dev/null +++ b/matrix-specification/Data/Capabilities.php @@ -0,0 +1,44 @@ +<?php + +namespace Matrix\Data; + +use Matrix\Data\Capability\BooleanCapability; +use Matrix\Data\Capability\ProfileFieldsCapability; +use Matrix\Data\Capability\RoomVersionsCapability; + +class Capabilities implements \JsonSerializable +{ + /** + * @param array<string, mixed> $otherProperties + */ + public function __construct( + private ?BooleanCapability $threePidChanges = null, + private ?BooleanCapability $changePassword = null, + private ?BooleanCapability $getLoginToken = null, + private ?ProfileFieldsCapability $profileFields = null, + private ?RoomVersionsCapability $roomVersions = null, + private ?BooleanCapability $setAvatarUrl = null, + private ?BooleanCapability $setDisplayname = null, + private ?array $otherProperties = null, + ) + {} + + public function jsonSerialize(): array + { + $data = [ + "m.3pid_changes" => $this->threePidChanges, + "m.change_password" => $this->changePassword, + "m.get_login_token" => $this->getLoginToken, + "m.profile_fields" => $this->profileFields, + "m.room_versions" => $this->roomVersions, + "m.set_avatar_url" => $this->setAvatarUrl, + "m.set_displayname" => $this->setDisplayname, + ]; + + if (! empty($this->otherProperties)) { + $data += $this->otherProperties; + } + + return array_filter($data, fn ($value) => ! is_null($value)); + } +} diff --git a/matrix-specification/Data/Capability/BooleanCapability.php b/matrix-specification/Data/Capability/BooleanCapability.php new file mode 100644 index 0000000..13cb4de --- /dev/null +++ b/matrix-specification/Data/Capability/BooleanCapability.php @@ -0,0 +1,18 @@ +<?php + +namespace Matrix\Data\Capability; + +class BooleanCapability implements \JsonSerializable +{ + public function __construct( + private bool $enabled, + ) + {} + + public function jsonSerialize(): array + { + return [ + "enabled" => $this->enabled, + ]; + } +} diff --git a/matrix-specification/Data/Capability/ProfileFieldsCapability.php b/matrix-specification/Data/Capability/ProfileFieldsCapability.php new file mode 100644 index 0000000..91b0fc4 --- /dev/null +++ b/matrix-specification/Data/Capability/ProfileFieldsCapability.php @@ -0,0 +1,26 @@ +<?php + +namespace Matrix\Data\Capability; + +class ProfileFieldsCapability implements \JsonSerializable +{ + /** + * @param string[] $allowed + * @param string[] $disallowed + */ + public function __construct( + private bool $enabled, + private ?array $allowed = null, + private ?array $disallowed = null, + ) + {} + + public function jsonSerialize(): array + { + return array_filter([ + "allowed" => $this->allowed, + "disallowed" => $this->disallowed, + "enabled" => $this->enabled, + ], fn ($value) => ! is_null($value)); + } +} diff --git a/matrix-specification/Data/Capability/RoomVersionsCapability.php b/matrix-specification/Data/Capability/RoomVersionsCapability.php new file mode 100644 index 0000000..d2a5cd7 --- /dev/null +++ b/matrix-specification/Data/Capability/RoomVersionsCapability.php @@ -0,0 +1,23 @@ +<?php + +namespace Matrix\Data\Capability; + +class RoomVersionsCapability implements \JsonSerializable +{ + /** + * @param array<string, string> $available + */ + public function __construct( + private array $available, + private string $default, + ) + {} + + public function jsonSerialize(): array + { + return [ + "available" => $this->available, + "default" => $this->default, + ]; + } +} diff --git a/migrations/20250819.php b/migrations/20250819.php index b635201..3476081 100644 --- a/migrations/20250819.php +++ b/migrations/20250819.php @@ -54,7 +54,7 @@ Database::getInstance()->query(<<<SQL "name" varchar(255) not null, - "version" integer not null + "version" text not null ); SQL); @@ -96,7 +96,7 @@ SQL); Database::getInstance()->query(<<<SQL create table if not exists "filters" ( - "id" varchar(255) primary key, + "id" varchar(255) not null, "account_data" jsonb, "event_fields" jsonb, "event_format" varchar(255), @@ -105,6 +105,39 @@ Database::getInstance()->query(<<<SQL "user_id" varchar(255) not null, + primary key (id, user_id), foreign key (user_id) references users(id) ); SQL); + +Database::getInstance()->query(<<<SQL + create table if not exists "device_keys" ( + "supported_algorithms" json not null, + "keys" json not null, + "signatures" json not null, + + "user_id" text not null, + "device_id" text not null, + + foreign key (user_id, device_id) references devices(user_id, id) + ); +SQL); + +Database::getInstance()->query(<<<SQL + create table if not exists "one_time_keys" ( + "id" text not null, + + "key" text not null, + "algorithm" text not null, + + "signature_key" text not null, + "signature_algorithm" text not null, + + "is_fallback" bool not null default false, + + "user_id" text not null, + "device_id" text not null, + + foreign key (user_id, device_id) references devices(user_id, id) + ); +SQL); diff --git a/src/App.php b/src/App.php index 33f71ef..e2376b1 100644 --- a/src/App.php +++ b/src/App.php @@ -6,8 +6,12 @@ use Symfony\Component\Dotenv\Dotenv; class App { + private static float $executionStartTime; + public function __construct() { + self::$executionStartTime = microtime(true); + $dotenv = new Dotenv(); $dotenv->load(dirname(__DIR__) . "/.env"); } @@ -16,4 +20,9 @@ class App { Router::getInstance()->run()->send(); } + + public static function getExectionTime(): float + { + return microtime(true) - self::$executionStartTime; + } } diff --git a/src/Controllers/Client/ClientController.php b/src/Controllers/Client/ClientController.php index c9eb1fa..cdce91e 100644 --- a/src/Controllers/Client/ClientController.php +++ b/src/Controllers/Client/ClientController.php @@ -2,6 +2,7 @@ namespace App\Controllers\Client; +use App\App; use App\Database; use App\Errors\AppException; use App\Errors\ErrorResponse; @@ -16,6 +17,7 @@ use App\Support\Logger; use App\Support\Parser; use App\Support\RequestValidator; use Matrix\Data\AccountData; +use Matrix\Data\Capabilities; use Matrix\Data\DeviceLists; use Matrix\Data\LoginFlow; use Matrix\Data\Presence; @@ -208,7 +210,7 @@ class ClientController #[Route(path: "_matrix/client/r0/sync", methods: ["GET"])] #[Route(path: "_matrix/client/v3/sync", methods: ["GET"])] public function sync(Request $request): Response - { + { $user = User::authenticateWithRequest($request); $filter = $request->query->get("filter", ""); @@ -216,7 +218,7 @@ class ClientController $setPresence = PresenceState::tryFrom($request->query->get("set_presence") ?? "") ?? PresenceState::ONLINE; $since = $request->query->get("since", ""); $timeout = $request->query->get("timeout", 0); - $useStateAfter = $request->query->get("use_state_after", false); + $useStateAfter = $request->query->get("use_state_after", false) ?: $request->query->get("org.matrix.msc4222.use_state_after", false); if (! empty($filter)) { if (str_starts_with($filter, "{")) { @@ -226,6 +228,18 @@ class ClientController } } + // device one time keys count + $deviceOneTimeKeysCount = Database::getInstance() + ->query("select count(*) from one_time_keys where user_id=:user_id and device_id=:device_id", [ + "user_id" => $user->getId(), + "device_id" => $user->getDeviceId(), + ]) + ->fetchColumn(); + $deviceOneTimeKeysCount = [ + "signed_curve25519" => $deviceOneTimeKeysCount, + ]; + + // rooms $rooms = Database::getInstance()->query(<<<SQL select * from rooms left join room_memberships @@ -240,20 +254,24 @@ class ClientController $knockedRooms = []; $leftRooms = []; + $nextBatch = (new \DateTime())->format("U"); + foreach ($rooms as $room) { $events = Database::getInstance()->query(<<<SQL select * from room_events where room_id = :room_id + and origin_server_timestamp > to_timestamp(:since) SQL, [ "room_id" => $room["room_id"], #"limit" => ($filter["room"]["timeline"]["limit"] ?? false) ? "limit " . $filter["room"]["timeline"]["limit"] : "", + "since" => (empty($since) ? \DateTime::createFromTimestamp(0) : \DateTime::createFromTimestamp($since))->format("U"), ])->fetchAll(); - if ($since === "" && MembershipState::tryFrom($room["state"]) === MembershipState::JOIN) { + if (! empty($events) && MembershipState::tryFrom($room["state"]) === MembershipState::JOIN) { $joinedRooms[$room["room_id"]] = new JoinedRoom( accountData: new AccountData([]), ephemeral: new Ephemeral([]), - state: new State([]), + state: new State(array_map([RoomEvent::class, "transformEvent"], $events)), summary: new RoomSummary( heroes: [], invitedMemberCount: 0, @@ -268,18 +286,25 @@ class ClientController unreadThreadNotifications: [], ); } + + if (! empty($events)) { + $newestEvent = RoomEvent::transformEvent($events[array_key_last($events)]); + $nextBatch = \DateTime::createFromTimestamp($newestEvent->getOriginServerTimestamp())->format("U"); + } + } + + if (($timeout / 1000) > App::getExectionTime()) { + sleep(intval(($timeout / 1000) - App::getExectionTime())); } return new JsonResponse(new ClientSyncGetResponse( - nextBatch: "1", + nextBatch: $nextBatch, accountData: new AccountData([]), deviceLists: new DeviceLists([], []), - deviceOneTimeKeysCount: [ - "signed_curve25519" => 10, - ], + deviceOneTimeKeysCount: $deviceOneTimeKeysCount, presence: new Presence([ new PresenceEvent( @@ -393,4 +418,25 @@ class ClientController ), ]); } + + #[Route(path: "/_matrix/client/v3/capabilities", methods: ["GET"])] + public function capabilities(Request $request): Response + { + $user = User::authenticateWithRequest($request); + + return new JsonResponse(new Capabilities()); + } + + #[Route(path: "/_matrix/client/v3/voip/turnServer", methods: ["GET"])] + public function voipTurnServer(Request $request): Response + { + $user = User::authenticateWithRequest($request); + + return new JsonResponse([ + "password" => "", + "ttl" => 86400, + "uris" => [], + "username" => "", + ]); + } } diff --git a/src/Controllers/Client/KeyController.php b/src/Controllers/Client/KeyController.php index b9ae61f..5e3245b 100644 --- a/src/Controllers/Client/KeyController.php +++ b/src/Controllers/Client/KeyController.php @@ -2,6 +2,7 @@ namespace App\Controllers\Client; +use App\Database; use App\Models\User; use App\Support\RequestValidator; use Matrix\Responses\ClientKeysUploadPostResponse; @@ -12,6 +13,76 @@ use Symfony\Component\Routing\Attribute\Route; class KeyController { + #[Route(path: "/_matrix/client/r0/keys/upload", methods: ["POST"])] + #[Route(path: "/_matrix/client/v3/keys/upload", methods: ["POST"])] + public function upload(Request $request): Response + { + $user = User::authenticateWithRequest($request); + $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); + + if (! empty($body["device_keys"])) { + if ($body["device_keys"]["user_id"] !== $user->getId()) {} + if ($body["device_keys"]["user_id"] !== $user->getDeviceId()) {} + + Database::getInstance()->query(<<<SQL + insert into device_keys (supported_algorithms, keys, signatures, user_id, device_id) + values (:supported_algorithms, :keys, :signatures, :user_id, :device_id) + SQL, [ + "supported_algorithms" => json_encode($body["device_keys"]["algorithms"]), + "keys" => json_encode($body["device_keys"]["keys"]), + "signatures" => json_encode($body["device_keys"]["signatures"]), + "user_id" => $user->getId(), + "device_id" => $user->getDeviceId(), + ]); + } + + $oneTimeKeys = $body["one_time_keys"]; + if (! empty($body["fallback_keys"])) { + $oneTimeKeys += $body["fallback_keys"]; + } + + foreach ($oneTimeKeys as $identifier => $object) { + $identifierParts = explode(":", $identifier); + + $algorithm = $identifierParts[0]; + $id = $identifierParts[1]; + + $signatures = array_values($object["signatures"])[0]; + $signatureIdentifier = array_keys($signatures)[0]; + $signatureAlgorithm = explode(":", $signatureIdentifier)[0]; + $signatureKey = array_values($signatures)[0]; + + $deviceId = explode(":", $signatureIdentifier)[1]; + + Database::getInstance()->query(<<<SQL + insert into one_time_keys (id, key, algorithm, signature_key, signature_algorithm, is_fallback, user_id, device_id) + values (:id, :key, :algorithm, :signature_key, :signature_algorithm, :is_fallback, :user_id, :device_id) + SQL, [ + "id" => $id, + "key" => $object["key"], + "algorithm" => $algorithm, + "signature_key" => $signatureKey, + "signature_algorithm" => $signatureAlgorithm, + "is_fallback" => ($object["fallback"] ?? false) ?: 0, + "user_id" => $user->getId(), + "device_id" => $deviceId, + ]); + } + + # TODO: do that per algorithm + $currentCountOneTimeKeys = Database::getInstance() + ->query("select count(id) from one_time_keys where user_id=:userId and is_fallback=false", [ + "userId" => $user->getId(), + ]) + ->fetchColumn(); + + return new JsonResponse(new ClientKeysUploadPostResponse([ + #"curve25519" => 0, + "signed_curve25519" => $currentCountOneTimeKeys, + ])); + } + #[Route(path: "/_matrix/client/v3/keys/query", methods: ["POST"])] public function query(Request $request): Response { @@ -29,19 +100,13 @@ class KeyController ]); } - #[Route(path: "/_matrix/client/r0/keys/upload", methods: ["POST"])] - #[Route(path: "/_matrix/client/v3/keys/upload", methods: ["POST"])] - public function upload(Request $request): Response + #[Route(path: "/_matrix/client/v3/keys/claim", methods: ["POST"])] + public function claim(Request $request): Response { $user = User::authenticateWithRequest($request); $body = json_decode($request->getContent(), true); RequestValidator::validateJson(); - - foreach ($body["one_time_keys"] as $identifier => $object) {} - - return new JsonResponse(new ClientKeysUploadPostResponse([ - #"curve25519" => 0, - "signed_curve25519" => count($body["one_time_keys"]), - ])); + + return new JsonResponse(); } } diff --git a/src/Controllers/Client/MediaController.php b/src/Controllers/Client/MediaController.php new file mode 100644 index 0000000..73f4aa0 --- /dev/null +++ b/src/Controllers/Client/MediaController.php @@ -0,0 +1,23 @@ +<?php + +namespace App\Controllers\Client; + +use App\Models\User; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +class MediaController +{ + #[Route(path: "/_matrix/media/v3/config", methods: ["GET"])] + #[Route(path: "/_matrix/client/v1/media/config", methods: ["GET"])] + public function mediaConfig(Request $request): Response + { + $user = User::authenticateWithRequest($request); + + return new JsonResponse([ + "m.upload.size" => 50000000, + ]); + } +} diff --git a/src/Controllers/Client/RoomController.php b/src/Controllers/Client/RoomController.php index ec04a2f..07bad27 100755 --- a/src/Controllers/Client/RoomController.php +++ b/src/Controllers/Client/RoomController.php @@ -75,10 +75,11 @@ class RoomController // create room $roomId = Id::generateRoomId(); Database::getInstance()->query(<<<SQL - insert into rooms (id, name) values (:id, :name) + insert into rooms (id, name, version) values (:id, :name, :version) SQL, [ "id" => $roomId, "name" => $roomAliasName, # "#$roomAliasName:$_ENV[DOMAIN]", + "version" => $roomVersion, ]); $roomCreateEvent = new RoomEvent(new CreateEvent( @@ -198,8 +199,47 @@ class RoomController "servers" => [], ]); } + + #[Route(path: "/_matrix/client/v3/rooms/{roomId}/join", methods: ["POST"])] + public function joinRoomId(Request $request): Response + { + return new JsonResponse(); + } + + #[Route(path: "/_matrix/client/v3/join/{roomIdOrAlias}", methods: ["POST"])] + public function joinRoomIdOrAlias(Request $request): Response + { + $user = User::authenticateWithRequest($request); + $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); + + $roomIdOrAlias = $request->attributes->get("roomIdOrAlias"); + + $via = $request->query->get("via", ""); + + $reason = $body["reason"] ?? ""; + #$thirdPartySigned + + /* + if (isRoomId()) { + $request->attributes->set("roomId", $roomIdOrAlias); + return $this->joinRoomId($request); + } + */ + + # TODO: db query id or name = value + $roomId = Database::getInstance() + ->query("select id from rooms where id=:value or name=:value", [ + "value" => $roomIdOrAlias, + ]) + ->fetchColumn(); + + return new JsonResponse([ + "room_id" => $roomId, + ]); + } - #[Route(path: "/_matrix/client/v3/{roomId}/messages", methods: ["GET"])] + #[Route(path: "/_matrix/client/v3/rooms/{roomId}/messages", methods: ["GET"])] public function getMessages(Request $request): Response { $user = User::authenticateWithRequest($request); @@ -282,7 +322,6 @@ class RoomController sender: $user->getId(), type: $eventType, unsigned: new UnsignedData( - age: 1234, # TODO membership: MembershipState::JOIN, ), )); diff --git a/src/Controllers/Client/UserController.php b/src/Controllers/Client/UserController.php index 038caba..277ba78 100755 --- a/src/Controllers/Client/UserController.php +++ b/src/Controllers/Client/UserController.php @@ -3,10 +3,12 @@ namespace App\Controllers\Client; use App\Database; +use App\Errors\AppException; use App\Errors\UnauthorizedError; use App\Models\Device; use App\Models\User; use App\Support\RequestValidator; +use Matrix\Enums\ErrorCode; use Matrix\Responses\ClientAccountWhoamiGetResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -68,4 +70,54 @@ class UserController "filter_id" => $filterId, ]); } + + #[Route(path: "/_matrix/client/v3/user/{userId}/{filter}/{filterId}", methods: ["GET"])] + public function getFilter(Request $request): Response + { + $user = User::authenticateWithRequest($request); + + $userId = $request->attributes->get("userId"); + $filterId = $request->attributes->get("filterId"); + + $filter = Database::getInstance() + ->query("select * from filters where id=:id and user_id=:user_id", [ + "id" => $filterId, + "user_id" => $userId, + ]) + ->fetch(); + + if (empty($filter)) { + throw new AppException( + ErrorCode::NOT_FOUND, + "Unknown filter.", + Response::HTTP_NOT_FOUND + ); + } + + return new JsonResponse([ + "account_data" => json_decode($filter["account_data"] ?? ""), + "event_fields" => json_decode($filter["event_fields"] ?? ""), + "event_format" => $filter["event_format"] ?? "", + "presence" => json_decode($filter["presence"] ?? ""), + "room" => json_decode($filter["room"] ?? ""), + ]); + } + + #[Route(path: "/_matrix/client/v3/profile/{userId}", methods: ["GET"])] + public function getProfile(Request $request): Response + { + $userId = $request->attributes->get("userId"); + + $user = Database::getInstance()->query("select * from users where id=:user_id", ["user_id" => $userId])->fetch(); + + if (empty($user)) { + throw new AppException( + ErrorCode::NOT_FOUND, + "There is no profile information for this user or this user does not exist.", + Response::HTTP_NOT_FOUND + ); + } + + return new JsonResponse(); + } } diff --git a/src/Models/RoomEvent.php b/src/Models/RoomEvent.php index ce74cac..11d74b0 100644 --- a/src/Models/RoomEvent.php +++ b/src/Models/RoomEvent.php @@ -22,7 +22,7 @@ class RoomEvent { $rowUnsigned = json_decode($row["unsigned"], true); $unsigned = new UnsignedData( - age: $row["age"] ?? null, + age: $row["age"] ?? ((time() - new \DateTime($row["origin_server_timestamp"])->getTimestamp()) * 1000), membership: $row["membership"] ?? null, previousContent: $row["previous_content"] ?? null, redactedBecause: $row["redacted_because"] ?? null, diff --git a/src/Models/User.php b/src/Models/User.php index 4c016ad..a30bee0 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -12,6 +12,8 @@ use Symfony\Component\HttpFoundation\Response; class User implements ConnectsToDatabase { + private string $deviceId; + public function __construct( private string $id, private string $name, @@ -62,7 +64,7 @@ class User implements ConnectsToDatabase public static function fetchWithAccessToken(string $accessToken): ?self { $row = Database::getInstance()->query(<<<SQL - select users.* from users left join tokens on tokens.user_id = users.id where tokens.access_token=:access_token + select users.*, tokens.device_id from users left join tokens on tokens.user_id = users.id where tokens.access_token=:access_token SQL, [ "access_token" => $accessToken, ])->fetch(); @@ -71,7 +73,10 @@ class User implements ConnectsToDatabase return null; } - return self::fromDatabase($row); + $user = self::fromDatabase($row); + $user->setDeviceId($row["device_id"]); + + return $user; } public static function new(string $id, string $name): self @@ -137,9 +142,25 @@ class User implements ConnectsToDatabase return $this->name; } + public function setDeviceId(string $id): void + { + $this->deviceId = $id; + } + + public function getDeviceId(): string + { + return $this->deviceId; + } + public function fetchDevice(string $id): ?Device { - return Device::fetch($id, $this->id); + $device = Device::fetch($id, $this->id); + + if ($device) { + $this->setDeviceId($device->getId()); + } + + return $device; } /** diff --git a/src/Router.php b/src/Router.php index ab0c37c..12aa8fe 100644 --- a/src/Router.php +++ b/src/Router.php @@ -101,6 +101,8 @@ class Router ); } + Logger::logResponseToFile($request, $response); + // add cors headers to all responses $response->headers->add($corsHeaders); diff --git a/src/Support/Logger.php b/src/Support/Logger.php index b01af11..2712a6e 100644 --- a/src/Support/Logger.php +++ b/src/Support/Logger.php @@ -4,6 +4,7 @@ namespace App\Support; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; class Logger implements LoggerInterface { @@ -28,6 +29,16 @@ class Logger implements LoggerInterface ); } + public static function logResponseToFile(Request $request, Response $response): void + { + $basePath = dirname(dirname(__DIR__)) . "/.cache/log/" . str_replace("/", "_", $request->getPathInfo()); + + file_put_contents( + $basePath . "-response.json", + json_encode(json_decode($response->getContent()), JSON_PRETTY_PRINT) + ); + } + public function emergency($message, array $context = []): void { } |
