summaryrefslogtreecommitdiff
path: root/src/Controllers/Client
diff options
context:
space:
mode:
Diffstat (limited to 'src/Controllers/Client')
-rw-r--r--src/Controllers/Client/ClientController.php292
-rw-r--r--src/Controllers/Client/KeyController.php47
-rwxr-xr-xsrc/Controllers/Client/RoomController.php295
-rw-r--r--src/Controllers/Client/ServerInformationController.php57
-rwxr-xr-xsrc/Controllers/Client/UserController.php71
5 files changed, 762 insertions, 0 deletions
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/Client/RoomController.php b/src/Controllers/Client/RoomController.php
new file mode 100755
index 0000000..ec04a2f
--- /dev/null
+++ b/src/Controllers/Client/RoomController.php
@@ -0,0 +1,295 @@
+<?php
+
+namespace App\Controllers\Client;
+
+use App\Database;
+use App\Errors\AppException;
+use App\Errors\Exception;
+use App\Errors\UnauthorizedError;
+use App\Models\RoomEvent;
+use App\Models\User;
+use App\Support\Id;
+use App\Support\Parser;
+use App\Support\RequestValidator;
+use Matrix\Data\UnsignedData;
+use Matrix\Enums\ErrorCode;
+use Matrix\Enums\EventType;
+use Matrix\Enums\MembershipState;
+use Matrix\Enums\MessageType;
+use Matrix\Enums\RoomGuestAccess;
+use Matrix\Enums\RoomHistoryVisibility;
+use Matrix\Enums\RoomJoinRule;
+use Matrix\Enums\RoomVisibility;
+use Matrix\Events\ClientEvent;
+use Matrix\Events\Room\CreateEvent;
+use Matrix\Events\Room\GuestAccessEvent;
+use Matrix\Events\Room\HistoryVisibilityEvent;
+use Matrix\Events\Room\JoinRulesEvent;
+use Matrix\Events\Room\MemberEvent;
+use Matrix\Events\Room\NameEvent;
+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);
+ $body = json_decode($request->getContent(), true);
+ RequestValidator::validateJson();
+
+ $creationContent = $body["creation_content"] ?? [];
+ $initialState = $body["initial_state"] ?? [];
+ $invite = $body["invite"] ?? [];
+ $body["invite_3pid"] ?? [];
+ $body["is_direct"] ?? false;
+ $name = $body["name"] ?? "";
+ $body["power_level_content_override"] ?? [];
+ $preset = $body["preset"] ?? "";
+ $roomAliasName = $body["room_alias_name"] ?? "";
+ $roomVersion = $body["room_version"] ?? "DEFAULT_ROOM_VERSION";
+ $topic = $body["topic"] ?? "";
+ $visibility = RoomVisibility::tryFrom($body["visibility"] ?? "private");
+
+ if (! $preset) {
+ $preset = $visibility->value . "_chat";
+ }
+
+ # TODO: get events for preset
+ # TODO: override preset events with initial state
+ # TODO: override events with name and topic if applicable
+ $state = [];
+
+ if ($name) {
+ $state[EventType::ROOM_NAME->value] = $name;
+ }
+
+ if ($topic) {
+ $state[EventType::ROOM_TOPIC->value] = $topic;
+ }
+
+ // create room
+ $roomId = Id::generateRoomId();
+ Database::getInstance()->query(<<<SQL
+ insert into rooms (id, name) values (:id, :name)
+ SQL, [
+ "id" => $roomId,
+ "name" => $roomAliasName, # "#$roomAliasName:$_ENV[DOMAIN]",
+ ]);
+
+ $roomCreateEvent = new RoomEvent(new CreateEvent(
+ eventId: Id::generateEventId(),
+ originServerTimestamp: time(),
+ roomId: $roomId,
+ sender: $user->getId(),
+ roomVersion: "12",
+ ));
+ $roomCreateEvent->insert();
+
+ $roomMemberEvent = new RoomEvent(new MemberEvent(
+ eventId: Id::generateEventId(),
+ originServerTimestamp: time(),
+ roomId: $roomId,
+ sender: $user->getId(),
+ stateKey: $user->getId(),
+ isDirect: false,
+ membership: MembershipState::JOIN,
+ displayName: $user->getName(),
+ ));
+ $roomMemberEvent->insert();
+
+ $roomPowerLevelsEvent = new RoomEvent(new PowerLevelsEvent(
+ eventId: Id::generateEventId(),
+ originServerTimestamp: time(),
+ roomId: $roomId,
+ sender: $user->getId(),
+ ));
+ $roomPowerLevelsEvent->insert();
+
+ $roomJoinRulesEvent = new RoomEvent(new JoinRulesEvent(
+ eventId: Id::generateEventId(),
+ originServerTimestamp: time(),
+ roomId: $roomId,
+ sender: $user->getId(),
+ joinRule: RoomJoinRule::INVITE,
+ ));
+ $roomJoinRulesEvent->insert();
+
+ $roomHistoryVisibilityEvent = new RoomEvent(new HistoryVisibilityEvent(
+ eventId: Id::generateEventId(),
+ originServerTimestamp: time(),
+ roomId: $roomId,
+ sender: $user->getId(),
+ historyVisibility: RoomHistoryVisibility::SHARED,
+ ));
+ $roomHistoryVisibilityEvent->insert();
+
+ $roomGuestAccessEvent = new RoomEvent(new GuestAccessEvent(
+ eventId: Id::generateEventId(),
+ originServerTimestamp: time(),
+ roomId: $roomId,
+ sender: $user->getId(),
+ guestAccess: RoomGuestAccess::CAN_JOIN,
+ ));
+ $roomGuestAccessEvent->insert();
+
+ $roomNameEvent = new RoomEvent(new NameEvent(
+ eventId: Id::generateEventId(),
+ originServerTimestamp: time(),
+ roomId: $roomId,
+ sender: $user->getId(),
+ name: $roomAliasName,
+ ));
+ $roomNameEvent->insert();
+
+ Database::getInstance()->query(<<<SQL
+ insert into room_memberships (room_id, user_id, state)
+ values (:room_id, :user_id, :state)
+ SQL, [
+ "room_id" => $roomId,
+ "user_id" => $user->getId(),
+ "state" => MembershipState::JOIN->value,
+ ]);
+
+ return new JsonResponse([
+ "room_id" => $roomId,
+ ]);
+ }
+
+ /**
+ * @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");
+
+ $roomAlias = Parser::parseRoomAlias($alias); # TODO: on parse error => 400
+ $roomId = null;
+
+ if ($roomAlias["server"] != $_ENV["DOMAIN"]) {
+ # TODO: federation API resolve
+ $roomId = -1;
+ }
+ else {
+ $room = Database::getInstance()->query(<<<SQL
+ select id from rooms where name = :name
+ SQL, [
+ "name" => $roomAlias["name"],
+ ])->fetch();
+
+ $roomId = $room["id"] ?? null;
+ }
+
+ if (empty($roomId)) {
+ throw new AppException(
+ ErrorCode::NOT_FOUND,
+ "Room alias $alias not found.",
+ Response::HTTP_NOT_FOUND
+ );
+ }
+
+ return new JsonResponse([
+ "room_id" => $roomId,
+ "servers" => [],
+ ]);
+ }
+
+ #[Route(path: "/_matrix/client/v3/{roomId}/messages", methods: ["GET"])]
+ public function getMessages(Request $request): Response
+ {
+ $user = User::authenticateWithRequest($request);
+
+ $roomId = $request->attributes->get("roomId");
+
+ $membership = Database::getInstance()
+ ->query("select state from room_memberships where user_id=:user_id and room_id=:room_id", [
+ "user_id" => $user->getId(),
+ "room_id" => $roomId,
+ ])
+ ->fetchColumn();
+
+ if (MembershipState::from($membership) !== MembershipState::JOIN) {
+ throw new Exception(ErrorCode::FORBIDDEN, "You aren't a member of the room.", Response::HTTP_FORBIDDEN);
+ }
+
+ $direction = $request->query->get("dir");
+ $filter = $request->query->get("filter");
+ $from = $request->query->get("from");
+ $limit = $request->query->get("limit", 10);
+ $to = $request->query->get("to");
+
+ $events = Database::getInstance()->query(<<<SQL
+ select * from room_events
+ where room_id = :room_id
+ SQL, [
+ "room_id" => $roomId,
+ #"limit" => ($filter["room"]["timeline"]["limit"] ?? false) ? "limit " . $filter["room"]["timeline"]["limit"] : "",
+ ])->fetchAll();
+
+ return new JsonResponse([
+ "chunk" => array_map([RoomEvent::class, "transformEvent"], $events),
+ "end" => "",
+ "start" => "",
+ "state" => [],
+ ]);
+ }
+
+ #[Route(path: "/_matrix/client/v3/rooms/{roomId}/read_markers", methods: ["POST"])]
+ public function readMarkers(Request $request): Response
+ {
+ $user = User::authenticateWithRequest($request);
+ $body = json_decode($request->getContent(), true);
+ RequestValidator::validateJson();
+
+ $roomId = $request->attributes->get("roomId");
+
+ return new JsonResponse();
+ }
+
+ /**
+ * @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);
+
+ if (empty($user)) {
+ throw new UnauthorizedError();
+ }
+
+ $roomId = $request->attributes->get("roomId");
+ $eventType = EventType::from($request->attributes->get("eventType"));
+ $transactionId = $request->attributes->get("txnId");
+
+ $body = json_decode($request->getContent(), true);
+ RequestValidator::validateJson();
+
+ // validate msgtype
+ MessageType::from($body["msgtype"]);
+
+ $eventId = Id::generateEventId();
+ $event = new RoomEvent(new ClientEvent(
+ content: $body,
+ eventId: $eventId,
+ originServerTimestamp: time(),
+ roomId: $roomId,
+ sender: $user->getId(),
+ type: $eventType,
+ unsigned: new UnsignedData(
+ age: 1234, # TODO
+ membership: MembershipState::JOIN,
+ ),
+ ));
+ $event->insert();
+
+ return new JsonResponse([
+ "event_id" => $eventId,
+ ]);
+ }
+}
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/Client/UserController.php b/src/Controllers/Client/UserController.php
new file mode 100755
index 0000000..038caba
--- /dev/null
+++ b/src/Controllers/Client/UserController.php
@@ -0,0 +1,71 @@
+<?php
+
+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(),
+ ));
+ }
+
+ /**
+ * @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") ?: "");
+ $user = User::fetchWithAccessToken($accessToken);
+
+ if (empty($user)) {
+ throw new UnauthorizedError();
+ }
+
+ $userId = $request->get("userId");
+ if ($user->getId() !== $userId) {
+ throw new UnauthorizedError();
+ }
+
+ $body = json_decode($request->getContent(), true);
+ RequestValidator::validateJson();
+
+ $filterId = md5($userId . random_bytes(512));
+
+ Database::getInstance()->query(<<<SQL
+ insert into filters (id, account_data, event_fields, event_format, presence, room, user_id)
+ values (:id, :account_data, :event_fields, :event_format, :presence, :room, :user_id)
+ SQL, [
+ "id" => $filterId,
+ "account_data" => isset($body["account_data"]) ? json_encode($body["account_data"]) : null,
+ "event_fields" => isset($body["event_fields"]) ? json_encode($body["event_fields"]) : null,
+ "event_format" => isset($body["event_format"]) ? json_encode($body["event_format"]) : null,
+ "presence" => isset($body["presence"]) ? json_encode($body["presence"]) : null,
+ "room" => isset($body["room"]) ? json_encode($body["room"]) : null,
+ "user_id" => $userId,
+ ]);
+
+ return new JsonResponse([
+ "filter_id" => $filterId,
+ ]);
+ }
+}