diff options
18 files changed, 696 insertions, 611 deletions
diff --git a/composer.json b/composer.json index 5a1178e..2b51506 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "psr/log": "^3.0", "symfony/dotenv": "^7.3", "symfony/http-foundation": "^7.3", - "symfony/routing": "^7.3" + "symfony/routing": "^7.3", + "symfony/config": "^8.0" }, "require-dev": { "guzzlehttp/guzzle": "^7.9", diff --git a/composer.lock b/composer.lock index d720eb4..253dba4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4964ee19b991107643bde192528a6119", + "content-hash": "1db0630d632f53cdc961a9f51269f376", "packages": [ { "name": "psr/http-message", @@ -110,6 +110,84 @@ "time": "2024-09-11T13:17:53+00:00" }, { + "name": "symfony/config", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39", + "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { "name": "symfony/deprecation-contracts", "version": "v3.6.0", "source": { @@ -255,6 +333,76 @@ "time": "2025-07-10T08:29:33+00:00" }, { + "name": "symfony/filesystem", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a", + "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { "name": "symfony/http-foundation", "version": "v7.3.3", "source": { @@ -338,6 +486,89 @@ "time": "2025-08-20T08:04:18+00:00" }, { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { "name": "symfony/polyfill-mbstring", "version": "v1.32.0", "source": { @@ -2691,5 +2922,5 @@ "prefer-lowest": false, "platform": {}, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/App.php b/src/App.php index 9b1edf9..33f71ef 100644 --- a/src/App.php +++ b/src/App.php @@ -2,7 +2,6 @@ namespace App; -use App\Router\Router; use Symfony\Component\Dotenv\Dotenv; class App diff --git a/src/Controllers/AccountController.php b/src/Controllers/AccountController.php deleted file mode 100755 index 8e20880..0000000 --- a/src/Controllers/AccountController.php +++ /dev/null @@ -1,29 +0,0 @@ -<?php - -namespace App\Controllers; - -use App\Models\Device; -use App\Models\User; -use Matrix\Responses\ClientAccountWhoamiGetResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\JsonResponse; - -class AccountController -{ - /** - * GET /_matrix/client/v3/account/whoami - * - * @see https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3accountwhoami - */ - public function whoami(Request $request): Response - { - $user = User::authenticateWithRequest($request); - $device = Device::fetch(userId: $user->getId()); - - return new JsonResponse(new ClientAccountWhoamiGetResponse( - userId: $user->getId(), - deviceId: $device->getId(), - )); - } -} diff --git a/src/Controllers/Client/ClientController.php b/src/Controllers/Client/ClientController.php new file mode 100644 index 0000000..053d288 --- /dev/null +++ b/src/Controllers/Client/ClientController.php @@ -0,0 +1,292 @@ +<?php + +namespace App\Controllers\Client; + +use App\Database; +use App\Errors\AppException; +use App\Errors\UnknownError; +use App\Models\Device; +use App\Models\RoomEvent; +use App\Models\Tokens; +use App\Models\User; +use App\Support\Logger; +use App\Support\Parser; +use App\Support\RequestValidator; +use Matrix\Data\AccountData; +use Matrix\Data\DeviceLists; +use Matrix\Data\LoginFlow; +use Matrix\Data\Presence; +use Matrix\Data\Room\Ephemeral; +use Matrix\Data\Room\JoinedRoom; +use Matrix\Data\Room\RoomSummary; +use Matrix\Data\Room\Rooms; +use Matrix\Data\Room\State; +use Matrix\Data\Room\Timeline; +use Matrix\Data\Room\UnreadNotificationCounts; +use Matrix\Data\ToDevice; +use Matrix\Enums\ErrorCode; +use Matrix\Enums\LoginType; +use Matrix\Enums\MembershipState; +use Matrix\Enums\PresenceState; +use Matrix\Enums\UserRegistrationKind; +use Matrix\Events\PresenceEvent; +use Matrix\Responses\ClientLoginGetResponse; +use Matrix\Responses\ClientLoginPostResponse; +use Matrix\Responses\ClientRefreshPostResponse; +use Matrix\Responses\ClientRegisterPostResponse; +use Matrix\Responses\ClientSyncGetResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Attribute\Route; + +class ClientController +{ + #[Route(path: "_matrix/client/r0/login", methods: ["GET"])] + #[Route(path: "_matrix/client/v3/login", methods: ["GET"])] + public function supportedLoginTypes(Request $request): Response + { + return new JsonResponse(new ClientLoginGetResponse([ + (new LoginFlow(LoginType::PASSWORD)), + ])); + } + + #[Route(path: "_matrix/client/r0/login", methods: ["POST"])] + #[Route(path: "_matrix/client/v3/login", methods: ["POST"])] + public function login(Request $request): Response + { + Logger::logRequestToFile($request); + + $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); + + // validate login type + $loginType = null; + try { + $loginType = LoginType::from($body["type"]); + } catch (\ValueError $error) { + throw new UnknownError("Bad login type.", Response::HTTP_BAD_REQUEST); + } + + // get user id + $userId = Parser::parseUser($body["identifier"]["user"]); + if (empty($userId["server"])) { + $userId = "@$userId[username]:$_ENV[DOMAIN]"; + #$userId = "@$userId[username]:localhost"; + } else { + $userId = "@$userId[username]:$userId[server]"; + } + + if ($loginType !== LoginType::PASSWORD) { + throw new AppException(ErrorCode::UNRECOGNIZED, "only password login supported for now", Response::HTTP_SERVICE_UNAVAILABLE); + } + + $user = User::fetchWithPassword($userId, $body["password"]); + + if (! $user) { + throw new AppException(ErrorCode::FORBIDDEN, "Invalid credentials", Response::HTTP_FORBIDDEN); + } + + $deviceId = $body["device_id"] ?? ""; + + $device = null; + $tokens = null; + + // create new device with tokens + if (empty($deviceId)) { + $device = Device::new( + $user->getId(), + initialDisplayName: $body["initial_device_display_name"] ?? "", + ); + $device->insert(); + + $tokens = Tokens::new($userId, $device->getId()); + $tokens->insert(); + } else { // fetch existing device and tokens + $device = $user->fetchDevice($deviceId); + $tokens = Tokens::fetch($userId, $device->getId()); + + if (empty($tokens)) { + throw new AppException( + ErrorCode::UNKNOWN_TOKEN, + "Soft logged out", + Response::HTTP_UNAUTHORIZED, + ["soft_logout" => true], + ); + } + } + + return new JsonResponse(new ClientLoginPostResponse( + accessToken: $tokens->getAccessToken(), + deviceId:$device->getId(), + userId: $user->getId(), + expiresInMilliseconds: $tokens->getExpiresIn(), + refreshToken: $tokens->getRefreshToken(), + )); + } + + #[Route(path: "_matrix/client/v3/register", methods: ["POST"])] + public function register(Request $request): Response + { + $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); + + // validate kind + $kind = null; + try { + $kind = UserRegistrationKind::from($request->query->get("kind") ?? "user"); + } catch (\ValueError $error) { + throw new UnknownError("Bad registration kind.", Response::HTTP_BAD_REQUEST); + } + + $username = $body["username"]; + $userId = "@$username:$_ENV[DOMAIN]"; + + Database::getInstance()->query("insert into users (id, password) values (:id, :password)", [ + "id" => $userId, + "password" => $body["password"], + ]); + + $device_id = $body["device_id"] ?? ""; + $initialDeviceDisplayName = $body["initial_device_display_name"] ?? ""; + + $device = Device::new($userId, $device_id, $initialDeviceDisplayName); + $device->insert(); + + $tokens = Tokens::new($userId, $device->getId()); + $tokens->insert(); + + return new JsonResponse(new ClientRegisterPostResponse( + accessToken: $tokens->getAccessToken(), + deviceId: $device->getId(), + expiresInMilliseconds: $tokens->getExpiresIn(), + refreshToken: $tokens->getRefreshToken(), + userId: $userId, + )); + } + + /** + * @see https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3sync + * @see https://spec.matrix.org/v1.15/client-server-api/#extensions-to-sync + */ + #[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", ""); + $syncFullState = $request->query->get("full_state", false); + $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); + + if (! empty($filter)) { + if (str_starts_with($filter, "{")) { + $filter = json_decode($filter, true); + } else { + $filter = Database::getInstance()->query("select * from filters where id=:id", ["id" => $filter])->fetch(); + } + } + + $rooms = Database::getInstance()->query(<<<SQL + select * from rooms + left join room_memberships + on rooms.id = room_memberships.room_id + where room_memberships.user_id = :user_id + SQL, [ + "user_id" => $user->getId(), + ])->fetchAll(); + + $invitedRooms = []; + $joinedRooms = []; + $knockedRooms = []; + $leftRooms = []; + + foreach ($rooms as $room) { + $events = Database::getInstance()->query(<<<SQL + select * from room_events + where room_id = :room_id + SQL, [ + "room_id" => $room["room_id"], + #"limit" => ($filter["room"]["timeline"]["limit"] ?? false) ? "limit " . $filter["room"]["timeline"]["limit"] : "", + ])->fetchAll(); + + if ($since === "" && MembershipState::tryFrom($room["state"]) === MembershipState::JOIN) { + $joinedRooms[$room["room_id"]] = new JoinedRoom( + accountData: new AccountData([]), + ephemeral: new Ephemeral([]), + state: new State([]), + summary: new RoomSummary( + heroes: [], + invitedMemberCount: 0, + joinedMemberCount: 1, + ), + timeline: new Timeline( + events: array_map([RoomEvent::class, "transformEvent"], $events), + limited: false,# $filter["room"]["timeline"]["limit"] ?? false, + previousBatch: null, + ), + unreadNotifications: new UnreadNotificationCounts(0, 0), + unreadThreadNotifications: [], + ); + } + } + + return new JsonResponse(new ClientSyncGetResponse( + nextBatch: "1", + + accountData: new AccountData([]), + + deviceLists: new DeviceLists([], []), + + deviceOneTimeKeysCount: [ + "signed_curve25519" => 10, + ], + + presence: new Presence([ + new PresenceEvent( + sender: $user->getId(), + presence: $setPresence, + ), + ]), + + rooms: new Rooms( + $invitedRooms, + $joinedRooms, + $knockedRooms, + $leftRooms, + ), + + toDevice: new ToDevice([]), + )); + } + + #[Route(path: "/_matrix/client/v3/refresh", methods: ["POST"])] + public function refresh(Request $request): Response + { + $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); + + $tokens = Tokens::fetchWithRefreshToken($body["refresh_token"]); + + if (empty($tokens)) { + throw new AppException( + ErrorCode::UNKNOWN_TOKEN, + "Soft logged out", + Response::HTTP_UNAUTHORIZED, + ["soft_logout" => true], + ); + } + + $newTokens = Tokens::new($tokens->getUserId(), $tokens->getDeviceId()); + $newTokens->insert(); + + return new JsonResponse(new ClientRefreshPostResponse( + accessToken: $newTokens->getAccessToken(), + expiresInMilliseconds: $newTokens->getExpiresIn(), + refreshToken: $newTokens->getRefreshToken(), + )); + } +} diff --git a/src/Controllers/Client/KeyController.php b/src/Controllers/Client/KeyController.php new file mode 100644 index 0000000..b9ae61f --- /dev/null +++ b/src/Controllers/Client/KeyController.php @@ -0,0 +1,47 @@ +<?php + +namespace App\Controllers\Client; + +use App\Models\User; +use App\Support\RequestValidator; +use Matrix\Responses\ClientKeysUploadPostResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Attribute\Route; + +class KeyController +{ + #[Route(path: "/_matrix/client/v3/keys/query", methods: ["POST"])] + public function query(Request $request): Response + { + $user = User::authenticateWithRequest($request); + $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); + + $deviceKeys = $body["device_keys"]; + $timeout = $body["timeout"] ?? 10000; + + foreach ($deviceKeys as $keysUserId => $deviceIds) {} + + return new JsonResponse([ + "device_keys" => [], + ]); + } + + #[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(); + + foreach ($body["one_time_keys"] as $identifier => $object) {} + + return new JsonResponse(new ClientKeysUploadPostResponse([ + #"curve25519" => 0, + "signed_curve25519" => count($body["one_time_keys"]), + ])); + } +} diff --git a/src/Controllers/RoomController.php b/src/Controllers/Client/RoomController.php index 060a030..ec04a2f 100755 --- a/src/Controllers/RoomController.php +++ b/src/Controllers/Client/RoomController.php @@ -1,6 +1,6 @@ <?php -namespace App\Controllers; +namespace App\Controllers\Client; use App\Database; use App\Errors\AppException; @@ -31,9 +31,11 @@ use Matrix\Events\Room\PowerLevelsEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Attribute\Route; class RoomController { + #[Route(path: "/_matrix/client/v3/createRoom", methods: ["POST"])] public function createRoom(Request $request): Response { $user = User::authenticateWithRequest($request); @@ -159,10 +161,9 @@ class RoomController } /** - * GET /_matrix/client/v3/directory/room/{roomAlias} - * * @see https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3directoryroomroomalias */ + #[Route(path: "/_matrix/client/v3/directory/room/{roomAlias}", methods: ["GET"])] public function resolveAlias(Request $request): Response { $alias = $request->attributes->get("roomAlias"); @@ -198,9 +199,7 @@ class RoomController ]); } - /** - * GET /_matrix/client/v3/rooms/{roomId}/messages - */ + #[Route(path: "/_matrix/client/v3/{roomId}/messages", methods: ["GET"])] public function getMessages(Request $request): Response { $user = User::authenticateWithRequest($request); @@ -240,9 +239,7 @@ class RoomController ]); } - /** - * POST /_matrix/client/v3/rooms/{roomId}/read_markers - */ + #[Route(path: "/_matrix/client/v3/rooms/{roomId}/read_markers", methods: ["POST"])] public function readMarkers(Request $request): Response { $user = User::authenticateWithRequest($request); @@ -255,10 +252,9 @@ class RoomController } /** - * PUT /_matrix/client/v3/rooms/{roomId}/send/{eventType}/{txnId} - * * @see https://spec.matrix.org/v1.15/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid */ + #[Route(path: "/_matrix/client/v3/rooms/{roomId}/send/{eventType}/{txnId}", methods: ["PUT"])] public function send(Request $request): Response { $user = User::authenticateWithRequest($request); diff --git a/src/Controllers/Client/ServerInformationController.php b/src/Controllers/Client/ServerInformationController.php new file mode 100644 index 0000000..51e68e6 --- /dev/null +++ b/src/Controllers/Client/ServerInformationController.php @@ -0,0 +1,57 @@ +<?php + +namespace App\Controllers\Client; + +use Matrix\Responses\ClientVersionsGetResponse; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +class ServerInformationController +{ + #[Route(path: "/.well-known/matrix/client", methods: ["GET"])] + public function client(Request $request): Response + { + return new JsonResponse([ + "m.homeserver" => [ + "base_url" => "https://$_ENV[DOMAIN]", + ], + ]); + } + + #[Route(path: "/.well-known/matrix/support", methods: ["GET"])] + public function support(Request $request): Response + { + return new JsonResponse([ + "contacts" => [], + "support_page" => "", + ]); + } + + #[Route(path: "/_matrix/client/versions", methods: ["GET"])] + public function versions(Request $request): Response + { + return new JsonResponse(new ClientVersionsGetResponse([ + "r0.0.1", + "v1.1", + ])); + } + + public function server(Request $request): Response + { + return new JsonResponse([ + "m.server" => "$_ENV[DOMAIN]:443", + ]); + } + + public function version(Request $request): Response + { + return new JsonResponse([ + "server" => [ + "name" => "Matrix PHP", + "version" => "0.1.0", + ], + ]); + } +} diff --git a/src/Controllers/UserController.php b/src/Controllers/Client/UserController.php index d102160..038caba 100755 --- a/src/Controllers/UserController.php +++ b/src/Controllers/Client/UserController.php @@ -1,23 +1,37 @@ <?php -namespace App\Controllers; +namespace App\Controllers\Client; use App\Database; use App\Errors\UnauthorizedError; +use App\Models\Device; use App\Models\User; use App\Support\RequestValidator; +use Matrix\Responses\ClientAccountWhoamiGetResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Attribute\Route; class UserController { + #[Route(path: "/_matrix/client/v3/account/whoami", methods: ["GET"])] + public function whoami(Request $request): Response + { + $user = User::authenticateWithRequest($request); + $device = Device::fetch(userId: $user->getId()); + + return new JsonResponse(new ClientAccountWhoamiGetResponse( + userId: $user->getId(), + deviceId: $device->getId(), + )); + } + /** - * POST /_matrix/client/r0/user/{userId}/filter - * POST /_matrix/client/v3/user/{userId}/filter - * * @see https://spec.matrix.org/v1.16/client-server-api/#post_matrixclientv3useruseridfilter */ + #[Route(path: "/_matrix/client/r0/user/{userId}/filter", methods: ["POST"])] + #[Route(path: "/_matrix/client/v3/user/{userId}/filter", methods: ["POST"])] public function uploadFilter(Request $request): Response { $accessToken = str_replace("Bearer ", "", $request->headers->get("authorization") ?: ""); diff --git a/src/Controllers/KeyController.php b/src/Controllers/KeyController.php deleted file mode 100644 index ebf580a..0000000 --- a/src/Controllers/KeyController.php +++ /dev/null @@ -1,78 +0,0 @@ -<?php - -namespace App\Controllers; - -use App\Errors\AppException; -use App\Models\Tokens; -use App\Models\User; -use App\Support\RequestValidator; -use Matrix\Enums\ErrorCode; -use Matrix\Responses\ClientKeysUploadPostResponse; -use Matrix\Responses\ClientRefreshPostResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\JsonResponse; - -class KeyController -{ - public function server(Request $request): Response - { - return new JsonResponse([ - "server" => [ - "name" => "Matrix PHP", - "version" => "0.1.0", - ], - ]); - } - - /** - * POST /_matrix/client/v3/keys/upload - */ - public function upload(Request $request): Response - { - $user = User::authenticateWithRequest($request); - $body = json_decode($request->getContent(), true); - RequestValidator::validateJson(); - - foreach ($body["one_time_keys"] as $identifier => $data) {} - - return new JsonResponse(new ClientKeysUploadPostResponse([ - #"curve25519" => 0, - "signed_curve25519" => count($body["one_time_keys"]), - ])); - } - - public function query(Request $request): Response - { - $serverName = $request->attributes->get("serverName"); - } - - /** - * POST /_matrix/client/v3/refresh - */ - public function refresh(Request $request): Response - { - $body = json_decode($request->getContent(), true); - RequestValidator::validateJson(); - - $tokens = Tokens::fetchWithRefreshToken($body["refresh_token"]); - - if (empty($tokens)) { - throw new AppException( - ErrorCode::UNKNOWN_TOKEN, - "Soft logged out", - Response::HTTP_UNAUTHORIZED, - ["soft_logout" => true], - ); - } - - $newTokens = Tokens::new($tokens->getUserId(), $tokens->getDeviceId()); - $newTokens->insert(); - - return new JsonResponse(new ClientRefreshPostResponse( - accessToken: $newTokens->getAccessToken(), - expiresInMilliseconds: $newTokens->getExpiresIn(), - refreshToken: $newTokens->getRefreshToken(), - )); - } -} diff --git a/src/Controllers/LoginController.php b/src/Controllers/LoginController.php deleted file mode 100644 index f9576fb..0000000 --- a/src/Controllers/LoginController.php +++ /dev/null @@ -1,151 +0,0 @@ -<?php - -namespace App\Controllers; - -use App\Database; -use App\Errors\AppException; -use App\Errors\UnknownError; -use App\Models\Device; -use App\Models\Tokens; -use App\Models\User; -use App\Support\Logger; -use App\Support\Parser; -use App\Support\RequestValidator; -use Matrix\Data\LoginFlow; -use Matrix\Enums\ErrorCode; -use Matrix\Enums\LoginType; -use Matrix\Enums\UserRegistrationKind; -use Matrix\Responses\ClientLoginGetResponse; -use Matrix\Responses\ClientLoginPostResponse; -use Matrix\Responses\ClientRegisterPostResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\JsonResponse; - -class LoginController -{ - /** - * GET /_matrix/client/r0/login - */ - public function supportedLoginTypes(Request $request): Response - { - return new JsonResponse(new ClientLoginGetResponse([ - (new LoginFlow(LoginType::PASSWORD)), - ])); - } - - /** - * POST /_matrix/client/v3/login - */ - public function login(Request $request): Response - { - Logger::logRequestToFile($request); - - $body = json_decode($request->getContent(), true); - RequestValidator::validateJson(); - - // validate login type - $loginType = null; - try { - $loginType = LoginType::from($body["type"]); - } catch (\ValueError $error) { - throw new UnknownError("Bad login type.", Response::HTTP_BAD_REQUEST); - } - - // get user id - $userId = Parser::parseUser($body["identifier"]["user"]); - if (empty($userId["server"])) { - #$userId = "@$userId[username]:$_ENV[DOMAIN]"; - $userId = "@$userId[username]:localhost"; - } else { - $userId = "@$userId[username]:$userId[server]"; - } - - #if ($loginType == LoginType::PASSWORD) {} - - $user = User::fetchWithPassword($userId, $body["password"]); - - if (! $user) { - throw new AppException(ErrorCode::FORBIDDEN, "Invalid credentials", Response::HTTP_FORBIDDEN); - } - - $deviceId = $body["device_id"] ?? ""; - - $device = null; - $tokens = null; - - // create new device with tokens - if (empty($deviceId)) { - $device = Device::new( - $user->getId(), - initialDisplayName: $body["initial_device_display_name"] ?? "", - ); - $device->insert(); - - $tokens = Tokens::new($userId, $device->getId()); - $tokens->insert(); - } else { // fetch existing device and tokens - $device = $user->fetchDevice($deviceId); - $tokens = Tokens::fetch($userId, $device->getId()); - - if (empty($tokens)) { - throw new AppException( - ErrorCode::UNKNOWN_TOKEN, - "Soft logged out", - Response::HTTP_UNAUTHORIZED, - ["soft_logout" => true], - ); - } - } - - return new JsonResponse(new ClientLoginPostResponse( - accessToken: $tokens->getAccessToken(), - deviceId:$device->getId(), - userId: $user->getId(), - expiresInMilliseconds: $tokens->getExpiresIn(), - refreshToken: $tokens->getRefreshToken(), - )); - } - - /** - * POST /_matrix/client/v3/register - */ - public function register(Request $request): Response - { - $body = json_decode($request->getContent(), true); - RequestValidator::validateJson(); - - // validate kind - $kind = null; - try { - $kind = UserRegistrationKind::from($request->query->get("kind") ?? "user"); - } catch (\ValueError $error) { - throw new UnknownError("Bad registration kind.", Response::HTTP_BAD_REQUEST); - } - - $username = $body["username"]; - $userId = "@$username:$_ENV[DOMAIN]"; - - Database::getInstance()->query("insert into users (id, password) values (:id, :password)", [ - "id" => $userId, - "password" => $body["password"], - ]); - - $device_id = $body["device_id"] ?? ""; - $initialDeviceDisplayName = $body["initial_device_display_name"] ?? ""; - - $device = Device::new($userId, $device_id, $initialDeviceDisplayName); - $device->insert(); - - $tokens = Tokens::new($userId, $device->getId()); - $tokens->insert(); - - return new JsonResponse(new ClientRegisterPostResponse( - accessToken: $tokens->getAccessToken(), - deviceId: $device->getId(), - expiresInMilliseconds: $tokens->getExpiresIn(), - refreshToken: $tokens->getRefreshToken(), - userId: $userId, - )); - } -} diff --git a/src/Controllers/ServerImplementationController.php b/src/Controllers/Server/ServerInformationController.php index 93b9a3f..a77bcca 100644 --- a/src/Controllers/ServerImplementationController.php +++ b/src/Controllers/Server/ServerInformationController.php @@ -1,28 +1,29 @@ <?php -namespace App\Controllers; +namespace App\Controllers\Server; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Attribute\Route; -class ServerImplementationController +class ServerInformationController { - public function version(Request $request): Response + #[Route(path: "/.well-known/matrix/server", methods: ["GET"])] + public function server(Request $request): Response { return new JsonResponse([ - "server" => [ - "name" => "Matrix PHP", - "version" => "0.1.0", - ], + "m.server" => "$_ENV[DOMAIN]:443", ]); } - public function versions(Request $request): Response + #[Route(path: "/_matrix/federation/v1/version", methods: ["GET"])] + public function version(Request $request): Response { return new JsonResponse([ - "versions" => [ - "v1.1", + "server" => [ + "name" => "Matrix PHP", + "version" => "0.1.0", ], ]); } diff --git a/src/Controllers/ServerDiscoveryController.php b/src/Controllers/ServerDiscoveryController.php deleted file mode 100644 index 917df14..0000000 --- a/src/Controllers/ServerDiscoveryController.php +++ /dev/null @@ -1,34 +0,0 @@ -<?php - -namespace App\Controllers; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\JsonResponse; - -class ServerDiscoveryController -{ - public function server(Request $request): Response - { - return new JsonResponse([ - "m.server" => "$_ENV[DOMAIN]:443", - ]); - } - - public function client(Request $request): Response - { - return new JsonResponse([ - "m.homeserver" => [ - "base_url" => "http://$_ENV[DOMAIN]", - ], - ]); - } - - public function support(Request $request): Response - { - return new JsonResponse([ - "contacts" => [], - "support_page" => "", - ]); - } -} diff --git a/src/Controllers/SyncController.php b/src/Controllers/SyncController.php deleted file mode 100755 index acebb11..0000000 --- a/src/Controllers/SyncController.php +++ /dev/null @@ -1,126 +0,0 @@ -<?php - -namespace App\Controllers; - -use App\Database; -use App\Models\RoomEvent; -use App\Models\User; -use Matrix\Data\AccountData; -use Matrix\Data\DeviceLists; -use Matrix\Data\Presence; -use Matrix\Data\Room\Ephemeral; -use Matrix\Data\Room\JoinedRoom; -use Matrix\Data\Room\RoomSummary; -use Matrix\Data\Room\Rooms; -use Matrix\Data\Room\State; -use Matrix\Data\Room\Timeline; -use Matrix\Data\Room\UnreadNotificationCounts; -use Matrix\Data\ToDevice; -use Matrix\Enums\MembershipState; -use Matrix\Enums\PresenceState; -use Matrix\Events\PresenceEvent; -use Matrix\Responses\ClientSyncGetResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\JsonResponse; - -class SyncController -{ - /** - * GET /_matrix/client/v3/sync - * - * @see https://spec.matrix.org/v1.15/client-server-api/#get_matrixclientv3sync - * @see https://spec.matrix.org/v1.15/client-server-api/#extensions-to-sync - */ - public function sync(Request $request): Response - { - $user = User::authenticateWithRequest($request); - - $filter = $request->query->get("filter", ""); - $syncFullState = $request->query->get("full_state", false); - $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); - - if (! empty($filter)) { - if (str_starts_with($filter, "{")) { - $filter = json_decode($filter, true); - } else { - $filter = Database::getInstance()->query("select * from filters where id=:id", ["id" => $filter])->fetch(); - } - } - - $rooms = Database::getInstance()->query(<<<SQL - select * from rooms - left join room_memberships - on rooms.id = room_memberships.room_id - where room_memberships.user_id = :user_id - SQL, [ - "user_id" => $user->getId(), - ])->fetchAll(); - - $invitedRooms = []; - $joinedRooms = []; - $knockedRooms = []; - $leftRooms = []; - - foreach ($rooms as $room) { - $events = Database::getInstance()->query(<<<SQL - select * from room_events - where room_id = :room_id - SQL, [ - "room_id" => $room["room_id"], - #"limit" => ($filter["room"]["timeline"]["limit"] ?? false) ? "limit " . $filter["room"]["timeline"]["limit"] : "", - ])->fetchAll(); - - if ($since === "" && MembershipState::tryFrom($room["state"]) === MembershipState::JOIN) { - $joinedRooms[$room["room_id"]] = new JoinedRoom( - accountData: new AccountData([]), - ephemeral: new Ephemeral([]), - state: new State([]), - summary: new RoomSummary( - heroes: [], - invitedMemberCount: 0, - joinedMemberCount: 1, - ), - timeline: new Timeline( - events: array_map([RoomEvent::class, "transformEvent"], $events), - limited: false,# $filter["room"]["timeline"]["limit"] ?? false, - previousBatch: null, - ), - unreadNotifications: new UnreadNotificationCounts(0, 0), - unreadThreadNotifications: [], - ); - } - } - - return new JsonResponse(new ClientSyncGetResponse( - nextBatch: "1", - - accountData: new AccountData([]), - - deviceLists: new DeviceLists([], []), - - deviceOneTimeKeysCount: [ - "signed_curve25519" => 10, - ], - - presence: new Presence([ - new PresenceEvent( - sender: $user->getId(), - presence: $setPresence, - ), - ]), - - rooms: new Rooms( - $invitedRooms, - $joinedRooms, - $knockedRooms, - $leftRooms, - ), - - toDevice: new ToDevice([]), - )); - } -} diff --git a/src/Models/User.php b/src/Models/User.php index b8aad62..b24afaf 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -22,7 +22,7 @@ class User implements ConnectsToDatabase { return new self( $row["id"], - $row["name"], + $row["name"] ?? "", ); } @@ -82,6 +82,11 @@ class User implements ConnectsToDatabase public static function authenticateWithRequest(Request $request): self { $accessToken = str_replace("Bearer ", "", $request->headers->get("authorization") ?: ""); + + if (empty($accessToken)) { + throw new AppException(ErrorCode::UNAUTHORIZED, "Missing access token", Response::HTTP_UNAUTHORIZED); + } + $user = self::fetchWithAccessToken($accessToken); if (empty($user)) { diff --git a/src/Router/Router.php b/src/Router.php index 6859771..cda3006 100644 --- a/src/Router/Router.php +++ b/src/Router.php @@ -1,19 +1,22 @@ <?php -namespace App\Router; +namespace App; use App\Errors\ErrorResponse; use App\Errors\Exception; use App\Singleton; use App\Support\Logger; use Matrix\Enums\ErrorCode; +use Symfony\Component\Config\FileLocator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; -use Symfony\Component\Routing\Loader\Configurator\RouteConfigurator; +use Symfony\Component\Routing\Loader\AttributeClassLoader; +use Symfony\Component\Routing\Loader\AttributeDirectoryLoader; use Symfony\Component\Routing\Matcher\UrlMatcher; use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; class Router @@ -21,32 +24,38 @@ class Router use Singleton; private RouteCollection $routes; - private RouteConfigurator $configurator; public function __construct() { - $this->routes = new RouteCollection(); - $this->configurator = new RouteConfigurator($this->routes, $this->routes); - - $this->addRoutes(); + // load routes + $loader = new AttributeDirectoryLoader(new FileLocator(), new class() extends AttributeClassLoader { + protected function configureRoute(Route $route, \ReflectionClass $class, \ReflectionMethod $method, object $attr) { + $route->setDefault("_controller", [$class->getName(), $method->getName()]); + } + }); + + $this->routes = $loader->load(__DIR__ . "/Controllers"); } /** * match the current url against the routes. - * also add preflight CORS headers on OPTIONS requests. + * also add CORS headers. */ public function run(): Response { $request = Request::createFromGlobals(); $response = new Response(); - $response->headers->add([ + $corsHeaders = [ "Access-Control-Allow-Origin" => "*", "Access-Control-Allow-Methods" => "GET, POST, PUT, DELETE, OPTIONS, PATCH, HEAD", "Access-Control-Allow-Headers" => "X-Requested-With, Content-Type, Authorization", - ]); + ]; + // handle OPTIONS if ($request->isMethod("OPTIONS")) { + $response->headers->add($corsHeaders); + return $response; } @@ -92,18 +101,9 @@ class Router ); } - return $response; - } + // add cors headers to all responses + $response->headers->add($corsHeaders); - /** - * add routes from the routes file - */ - private function addRoutes(): void - { - $routesClientServer = include_once(__DIR__ . "/routes_client_server.php"); - $routesClientServer($this->configurator); - - $routesServerServer = include_once(__DIR__ . "/routes_server_server.php"); - $routesServerServer($this->configurator); + return $response; } } diff --git a/src/Router/routes_client_server.php b/src/Router/routes_client_server.php deleted file mode 100644 index e888782..0000000 --- a/src/Router/routes_client_server.php +++ /dev/null @@ -1,116 +0,0 @@ -<?php - -namespace App\Router; - -use App\Controllers\AccountController; -use App\Controllers\KeyController; -use App\Controllers\LoginController; -use App\Controllers\RoomController; -use App\Controllers\ServerDiscoveryController; -use App\Controllers\ServerImplementationController; -use App\Controllers\SyncController; -use App\Controllers\UserController; -use Symfony\Component\Routing\Loader\Configurator\RouteConfigurator; - -return function (RouteConfigurator $routes): void -{ - $routes - ->add("well_known_matrix_client", "/.well-known/matrix/client") - ->controller([ServerDiscoveryController::class, "client"]) - ->methods(["GET"]); - - $routes - ->add("well_known_matrix_support", "/.well-known/matrix/support") - ->controller([ServerDiscoveryController::class, "support"]) - ->methods(["GET"]); - - $routes - ->add("matrix_client_versions", "/_matrix/client/versions") - ->controller([ServerImplementationController::class, "versions"]) - ->methods(["GET"]); - - $supportedLoginTypes = [LoginController::class, "supportedLoginTypes"]; - $routes - ->add("matrix_client_r0_login_types", "/_matrix/client/r0/login") - ->controller($supportedLoginTypes) - ->methods(["GET"]); - $routes - ->add("matrix_client_v3_login_types", "/_matrix/client/v3/login") - ->controller($supportedLoginTypes) - ->methods(["GET"]); - - $routes - ->add("matrix_client_r0_login", "/_matrix/client/r0/login") - ->controller([LoginController::class, "login"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_v3_login", "/_matrix/client/v3/login") - ->controller([LoginController::class, "login"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_v3_keys_upload", "/_matrix/client/v3/keys/upload") - ->controller([KeyController::class, "upload"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_r0_keys_upload", "/_matrix/client/r0/keys/upload") - ->controller([KeyController::class, "upload"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_v3_sync", "/_matrix/client/v3/sync") - ->controller([SyncController::class, "sync"]) - ->methods(["GET"]); - - $routes - ->add("matrix_client_r0_sync", "/_matrix/client/r0/sync") - ->controller([SyncController::class, "sync"]) - ->methods(["GET"]); - - $routes - ->add("matrix_client_v3_refresh", "/_matrix/client/v3/refresh") - ->controller([KeyController::class, "refresh"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_v3_directory_room_alias_get", "/_matrix/client/v3/directory/room/{roomAlias}") - ->controller([RoomController::class, "resolveAlias"]) - ->methods(["GET"]); - - $routes - ->add("matrix_client_v3_account_whoami", "/_matrix/client/v3/account/whoami") - ->controller([AccountController::class, "whoami"]) - ->methods(["GET"]); - - $routes - ->add("matrix_client_v3_rooms_id_send_event_transaction", "/_matrix/client/v3/rooms/{roomId}/send/{eventType}/{txnId}") - ->controller([RoomController::class, "send"]) - ->methods(["PUT"]); - - $routes - ->add("matrix_client_r0_user_id_filter", "/_matrix/client/r0/user/{userId}/filter") - ->controller([UserController::class, "uploadFilter"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_v3_user_id_filter", "/_matrix/client/v3/user/{userId}/filter") - ->controller([UserController::class, "uploadFilter"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_v3_room_create", "/_matrix/client/v3/createRoom") - ->controller([RoomController::class, "createRoom"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_v3_rooms_id_read_markers", "/_matrix/client/v3/rooms/{roomId}/read_markers") - ->controller([RoomController::class, "readMarkers"]) - ->methods(["POST"]); - - $routes - ->add("matrix_client_v3_rooms_id_messages", "/_matrix/client/v3/rooms/{roomId}/messages") - ->controller([RoomController::class, "getMessages"]) - ->methods(["GET"]); -}; diff --git a/src/Router/routes_server_server.php b/src/Router/routes_server_server.php deleted file mode 100644 index 2e85a17..0000000 --- a/src/Router/routes_server_server.php +++ /dev/null @@ -1,24 +0,0 @@ -<?php - -namespace App\Router; - -use App\Controllers\ServerDiscoveryController; -use App\Controllers\ServerImplementationController; -use Symfony\Component\Routing\Loader\Configurator\RouteConfigurator; - -return function (RouteConfigurator $routes): void -{ - $routes - ->add("well_known_matrix_server", "/.well-known/matrix/server") - ->controller([ServerDiscoveryController::class, "server"]) - ->methods(["GET"]); - - $routes - ->add("matrix_federation_version", "/_matrix/federation/v1/version") - ->controller([ServerImplementationController::class, "version"]) - ->methods(["GET"]); - - # /_matrix/key/v2/server - # /_matrix/key/v2/query - # /_matrix/key/v2/query/{serverName} -}; |
