summaryrefslogtreecommitdiff
path: root/src/Controllers/Client/ClientController.php
diff options
context:
space:
mode:
Diffstat (limited to 'src/Controllers/Client/ClientController.php')
-rw-r--r--src/Controllers/Client/ClientController.php742
1 files changed, 742 insertions, 0 deletions
diff --git a/src/Controllers/Client/ClientController.php b/src/Controllers/Client/ClientController.php
new file mode 100644
index 0000000..28aea66
--- /dev/null
+++ b/src/Controllers/Client/ClientController.php
@@ -0,0 +1,742 @@
+<?php
+
+namespace App\Controllers\Client;
+
+use App\App;
+use App\Database;
+use App\Errors\AppException;
+use App\Errors\ErrorResponse;
+use App\Errors\UnauthorizedError;
+use App\Errors\UnknownError;
+use App\Models\Device;
+use App\Models\RoomEvent;
+use App\Models\Tokens;
+use App\Models\User;
+use App\Support\Id;
+use App\Support\Logger;
+use App\Support\Parser;
+use App\Support\RequestValidator;
+use Matrix\Data\AccountData;
+use Matrix\Data\Capabilities;
+use Matrix\Data\Capability\RoomVersionsCapability;
+use Matrix\Data\DeviceLists;
+use Matrix\Data\LoginFlow;
+use Matrix\Data\Presence;
+use Matrix\Data\PushCondition;
+use Matrix\Data\PushRule;
+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\Ruleset;
+use Matrix\Data\ToDevice;
+use Matrix\Data\UserId;
+use Matrix\Enums\AuthenticationType;
+use Matrix\Enums\ErrorCode;
+use Matrix\Enums\LoginType;
+use Matrix\Enums\MembershipState;
+use Matrix\Enums\PresenceState;
+use Matrix\Enums\PushConditionKind;
+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();
+
+ if (empty($body)) {
+ return new JsonResponse([
+ "completed" => [],
+ "flows" => [
+ [
+ "stages" => [
+ AuthenticationType::DUMMY,
+ ],
+ ],
+ ],
+ "params" => [],
+ "session" => Id::generate(),
+ ], Response::HTTP_UNAUTHORIZED);
+ }
+
+ // 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"] ?? "";
+ if (empty($username)) {
+ $username = crc32(time() . random_bytes(64));
+ }
+
+ $userId = "@$username:$_ENV[DOMAIN]";
+
+ if (empty($body["password"])) {
+ throw new AppException(ErrorCode::MISSING_PARAM, "missing password", Response::HTTP_BAD_REQUEST);
+ }
+
+ if (Database::getInstance()->query("select id from users where id=:id", ["id" => $userId])->fetchColumn()) {
+ throw new AppException(ErrorCode::USER_IN_USE, "The desired user ID is already taken.", Response::HTTP_BAD_REQUEST);
+ }
+
+ Database::getInstance()->query("insert into users (id, name, password) values (:id, :name, :password)", [
+ "id" => $userId,
+ "name" => $username,
+ "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) ?: $request->query->get("org.matrix.msc4222.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();
+ }
+ }
+
+ // 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
+ on rooms.id = room_memberships.room_id
+ where room_memberships.user_id = :user_id
+ SQL, [
+ "user_id" => $user->getId(),
+ ])->fetchAll();
+
+ $invitedRooms = [];
+ $joinedRooms = [];
+ $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 (! empty($events) && MembershipState::tryFrom($room["state"]) === MembershipState::JOIN) {
+ $joinedRooms[$room["room_id"]] = new JoinedRoom(
+ accountData: new AccountData([]),
+ ephemeral: new Ephemeral([]),
+ state: new State(array_map([RoomEvent::class, "transformEvent"], $events)),
+ 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: [],
+ );
+ }
+
+ 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: $nextBatch,
+
+ accountData: new AccountData([]),
+
+ deviceLists: new DeviceLists([], []),
+
+ deviceOneTimeKeysCount: $deviceOneTimeKeysCount,
+
+ 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(),
+ ));
+ }
+
+ #[Route(path: "/_matrix/client/v3/pushrules", methods: ["GET"])]
+ public function pushRules(Request $request): Response
+ {
+ $user = User::authenticateWithRequest($request);
+
+ $notificationActionBase = [
+ "notify",
+ ];
+ $notificationActionSound = $notificationActionBase + [
+ [
+ "set_tweak" => "sound",
+ "value" => "default",
+ ],
+ ];
+ $notificationActionHighlight = $notificationActionBase + [
+ [
+ "set_tweak" => "highlight",
+ ],
+ ];
+ $notifactionActionSoundHighlight = $notificationActionSound + [
+ [
+ "set_tweak" => "highlight",
+ ],
+ ];
+
+ // @see https://spec.matrix.org/v1.16/client-server-api/#predefined-rules
+ return new JsonResponse([
+ "global" => new Ruleset(
+ content: [
+ new PushRule(
+ ruleId: ".m.rule.contains_user_name",
+ default: true,
+ enabled: false,
+ pattern: Parser::parseUser($user->getId())["username"],
+ actions: [
+ ...$notifactionActionSoundHighlight,
+ ],
+ ),
+ ],
+
+ override: [
+ new PushRule(
+ ruleId: ".m.rule.master",
+ default: true,
+ enabled: false,
+ conditions: [],
+ actions: [],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.suppress_notices",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "content.msgtype",
+ pattern: "m.notice",
+ ),
+ ],
+ actions: [],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.invite_for_me",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.room.member",
+ ),
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "content.membership",
+ pattern: "invite",
+ ),
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "state_key",
+ pattern: $user->getId(),
+ ),
+ ],
+ actions: [
+ ...$notificationActionSound,
+ ],
+ ),
+
+
+ new PushRule(
+ ruleId: ".m.rule.member_event",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.room.member",
+ ),
+ ],
+ actions: [],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.is_user_mention",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_PROPERTY_CONTAINS,
+ key: "content.m\\.mentions.user_ids",
+ value: $user->getId(),
+ ),
+ ],
+ actions: [
+ ...$notifactionActionSoundHighlight,
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.contains_display_name",
+ default: true,
+ enabled: false,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::CONTAINS_DISPLAY_NAME,
+ ),
+ ],
+ actions: [
+ "notify",
+ [
+ "set_tweak" => "sound",
+ "value" => "default",
+ ],
+ [
+ "set_tweak" => "highlight",
+ ],
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.is_room_mention",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_PROPERTY_IS,
+ key: "content.m\\.mentions.room",
+ value: true,
+ ),
+ new PushCondition(
+ kind: PushConditionKind::SENDER_NOTIFICATION_PERMISSION,
+ key: "room",
+ ),
+ ],
+ actions: [
+ ...$notificationActionHighlight,
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.roomnotif",
+ default: true,
+ enabled: false,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "content.body",
+ pattern: "@room",
+ ),
+ new PushCondition(
+ kind: PushConditionKind::SENDER_NOTIFICATION_PERMISSION,
+ key: "room",
+ ),
+ ],
+ actions: [
+ "notify",
+ [
+ "set_tweak" => "highlight",
+ ],
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.tombstone",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.room.tombstone",
+ ),
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "state_key",
+ pattern: "",
+ ),
+ ],
+ actions: [
+ ...$notificationActionHighlight,
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.reaction",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.reaction",
+ ),
+ ],
+ actions: [],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.room.server_acl",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.room.server_acl",
+ ),
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "state_key",
+ pattern: "",
+ ),
+ ],
+ actions: [],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.suppress_edits",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_PROPERTY_IS,
+ key: "content.m\\.relates_to.rel_type",
+ value: "m.replace",
+ ),
+ ],
+ actions: [],
+ ),
+ ],
+
+ room: [],
+ sender: [],
+
+ underride: [
+ new PushRule(
+ ruleId: ".m.rule.call",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.call.invite",
+ )
+ ],
+ actions: [
+ "notify",
+ [
+ "set_tweak" => "sound",
+ "value" => "ring",
+ ],
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.encrypted_room_one_to_one",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::ROOM_MEMBER_COUNT,
+ is: "2",
+ ),
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.room.encrypted",
+ ),
+ ],
+ actions: [
+ ...$notificationActionSound,
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.room_one_to_one",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::ROOM_MEMBER_COUNT,
+ is: "2",
+ ),
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.room.message",
+ ),
+ ],
+ actions: [
+ ...$notificationActionSound,
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.message",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.room.message",
+ ),
+ ],
+ actions: [
+ "notify",
+ ],
+ ),
+
+ new PushRule(
+ ruleId: ".m.rule.encrypted",
+ default: true,
+ enabled: true,
+ conditions: [
+ new PushCondition(
+ kind: PushConditionKind::EVENT_MATCH,
+ key: "type",
+ pattern: "m.room.encrypted",
+ ),
+ ],
+ actions: [
+ "notify",
+ ],
+ ),
+ ],
+ ),
+ ]);
+ }
+
+ #[Route(path: "/_matrix/client/v3/capabilities", methods: ["GET"])]
+ public function capabilities(Request $request): Response
+ {
+ $user = User::authenticateWithRequest($request);
+
+ return new JsonResponse(new Capabilities(
+ roomVersions: new RoomVersionsCapability(
+ available: ["1" => "stable"],
+ default: "1",
+ ),
+ ));
+ }
+
+ #[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" => "",
+ ]);
+ }
+
+ #[Route(path: "/_matrix/client/v3/thirdparty/protocols", methods: ["GET"])]
+ public function thirdPartyProtocols(Request $request): Response
+ {
+ $user = User::authenticateWithRequest($request);
+
+ return new JsonResponse(new \stdClass());
+ }
+}