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-- | matrix-specification/Data/PushRule.php | 2 | ||||
| -rw-r--r-- | matrix-specification/Enums/PushConditionKind.php | 1 | ||||
| -rw-r--r-- | migrations/20250819.php | 60 | ||||
| -rw-r--r-- | src/App.php | 9 | ||||
| -rw-r--r-- | src/Controllers/Client/ClientController.php | 392 | ||||
| -rw-r--r-- | src/Controllers/Client/KeyController.php | 119 | ||||
| -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 | 113 | ||||
| -rwxr-xr-x | src/Errors/NotFoundError.php | 19 | ||||
| -rwxr-xr-x | src/Errors/UnauthorizedError.php | 4 | ||||
| -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 |
19 files changed, 893 insertions, 47 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/matrix-specification/Data/PushRule.php b/matrix-specification/Data/PushRule.php index 2217a78..f904b3a 100644 --- a/matrix-specification/Data/PushRule.php +++ b/matrix-specification/Data/PushRule.php @@ -5,7 +5,7 @@ namespace Matrix\Data; class PushRule implements \JsonSerializable { /** - * @param array<string|array> $actions + * @param array<string|array<string, mixed>> $actions * @param PushCondition[] $conditions */ public function __construct( diff --git a/matrix-specification/Enums/PushConditionKind.php b/matrix-specification/Enums/PushConditionKind.php index fe61c16..41223aa 100644 --- a/matrix-specification/Enums/PushConditionKind.php +++ b/matrix-specification/Enums/PushConditionKind.php @@ -4,6 +4,7 @@ namespace Matrix\Enums; enum PushConditionKind: string implements \JsonSerializable { + case CONTAINS_DISPLAY_NAME = "contains_display_name"; case EVENT_MATCH = "event_match"; case EVENT_PROPERTY_CONTAINS = "event_property_contains"; case EVENT_PROPERTY_IS = "event_property_is"; diff --git a/migrations/20250819.php b/migrations/20250819.php index b635201..6e3c59b 100644 --- a/migrations/20250819.php +++ b/migrations/20250819.php @@ -20,6 +20,29 @@ Database::getInstance()->query(<<<SQL SQL); Database::getInstance()->query(<<<SQL + create table if not exists "account_data" ( + "key" text not null, + "value" jsonb not null, + + "user_id" text references users(id) not null, + + primary key (user_id, key) + ); +SQL); + +Database::getInstance()->query(<<<SQL + create table if not exists "room_account_data" ( + "key" text not null, + "value" jsonb not null, + + "user_id" text references users(id) not null, + "room_id" text references rooms(id) not null, + + primary key (user_id, room_id, key) + ); +SQL); + +Database::getInstance()->query(<<<SQL create table if not exists "devices" ( "id" varchar(255) not null, "name" varchar(255) not null, @@ -54,7 +77,7 @@ Database::getInstance()->query(<<<SQL "name" varchar(255) not null, - "version" integer not null + "version" text not null ); SQL); @@ -96,7 +119,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 +128,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..28aea66 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,8 @@ 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; @@ -30,6 +33,7 @@ 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; @@ -126,7 +130,7 @@ class ClientController return new JsonResponse(new ClientLoginPostResponse( accessToken: $tokens->getAccessToken(), - deviceId:$device->getId(), + deviceId: $device->getId(), userId: $user->getId(), expiresInMilliseconds: $tokens->getExpiresIn(), refreshToken: $tokens->getRefreshToken(), @@ -208,7 +212,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 +220,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 +230,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 +256,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 +288,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( @@ -331,10 +358,130 @@ class ClientController { $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", [ @@ -345,20 +492,117 @@ class ClientController "set_tweak" => "highlight", ], ], + ), + + new PushRule( + ruleId: ".m.rule.is_room_mention", default: true, enabled: true, - pattern: "alice", - ruleId: ".m.rule.contains_user_name", + 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, + ], ), - ], - override: [ new PushRule( + ruleId: ".m.rule.reaction", + default: true, + enabled: true, + conditions: [ + new PushCondition( + kind: PushConditionKind::EVENT_MATCH, + key: "type", + pattern: "m.reaction", + ), + ], actions: [], - conditions: [], + ), + + new PushRule( + ruleId: ".m.rule.room.server_acl", default: true, - enabled: false, - ruleId: ".m.rule.master", + 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: [], ), ], @@ -367,30 +611,132 @@ class ClientController 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", ], - [ - "set_tweak" => "highlight", - "value" => false, - ], ], + ), + + 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.call.invite", - ) + pattern: "m.room.encrypted", + ), ], + actions: [ + ...$notificationActionSound, + ], + ), + + new PushRule( + ruleId: ".m.rule.room_one_to_one", default: true, enabled: true, - ruleId: ".m.rule.master", + 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()); + } } diff --git a/src/Controllers/Client/KeyController.php b/src/Controllers/Client/KeyController.php index b9ae61f..47f8933 100644 --- a/src/Controllers/Client/KeyController.php +++ b/src/Controllers/Client/KeyController.php @@ -2,6 +2,9 @@ namespace App\Controllers\Client; +use App\App; +use App\Database; +use App\Models\Device; use App\Models\User; use App\Support\RequestValidator; use Matrix\Responses\ClientKeysUploadPostResponse; @@ -12,6 +15,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 { @@ -22,26 +95,48 @@ class KeyController $deviceKeys = $body["device_keys"]; $timeout = $body["timeout"] ?? 10000; - foreach ($deviceKeys as $keysUserId => $deviceIds) {} + $downloadedDeviceKeys = []; + foreach ($deviceKeys as $keysUserId => $deviceIds) { + foreach ($deviceIds as $deviceId) { + $result = Database::getInstance() + ->query("select * from device_keys where user_id=:user_id and device_id=:device_id", [ + "user_id" => $keysUserId, + "device_id" => $deviceId, + ]) + ->fetch(); + $device = Device::fetch($deviceId, $keysUserId); + + $downloadedDeviceKeys[$keysUserId][$deviceId] = [ + "algorithms" => $result["supported_algorithms"], + "keys" => $result["keys"], + "signatures" => $result["signatures"], + "device_id" => $result["device_id"], + "user_id" => $result["user_id"], + "unsigned" => [ + "device_display_name" => $device->getName(), + ], + ]; + } + } + + // apply timeout + if ($timeout > 0) { + sleep(intval(($timeout / 1000) - App::getExectionTime())); + } + return new JsonResponse([ - "device_keys" => [], + "device_keys" => empty($downloadedDeviceKeys) ? new \stdClass() : $downloadedDeviceKeys, ]); } - #[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..63442ec 100755 --- a/src/Controllers/Client/UserController.php +++ b/src/Controllers/Client/UserController.php @@ -3,10 +3,14 @@ namespace App\Controllers\Client; use App\Database; +use App\Errors\AppException; +use App\Errors\NotFoundError; use App\Errors\UnauthorizedError; use App\Models\Device; use App\Models\User; use App\Support\RequestValidator; +use Cassandra\Exception\UnauthorizedException; +use Matrix\Enums\ErrorCode; use Matrix\Responses\ClientAccountWhoamiGetResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -68,4 +72,113 @@ 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(); + } + + #[Route(path: "/_matrix/client/v3/user/{userId}/account_data/{type}", methods: ["GET"])] + public function getAccountData(Request $request): Response + { + $user = User::authenticateWithRequest($request); + + $userId = $request->attributes->get("userId"); + $type = $request->attributes->get("type"); + + if ($user->getId() !== $userId) { + throw new UnauthorizedError("Cannot get account data for other users."); + } + + $value = Database::getInstance() + ->query("select value from account_data where user_id=:user_id and key=:key", [ + "user_id" => $userId, + "key" => $type, + ]) + ->fetchColumn(); + + if (empty($value)) { + throw new NotFoundError("Account data not found."); + } + + return new JsonResponse([ + $type => $value, + ]); + } + + #[Route(path: "/_matrix/client/v3/user/{userId}/account_data/{type}", methods: ["PUT"])] + public function setAccountData(Request $request): Response + { + $user = User::authenticateWithRequest($request); + $body = json_decode($request->getContent(), true); + RequestValidator::validateJson(); + + $userId = $request->attributes->get("userId"); + $type = $request->attributes->get("type"); + + if ($user->getId() !== $userId) { + throw new UnauthorizedError("Cannot add account data for other users."); + } + + Database::getInstance() + ->query( + <<<SQL + insert into account_data (key, value, user_id) values (:key, :value, :user_id) + on conflict (user_id, key) do update set + value = excluded.value + SQL, + [ + "key" => $type, + "value" => $request->getContent(), + "user_id" => $userId, + ] + ); + + return new JsonResponse(); + } } diff --git a/src/Errors/NotFoundError.php b/src/Errors/NotFoundError.php new file mode 100755 index 0000000..a128509 --- /dev/null +++ b/src/Errors/NotFoundError.php @@ -0,0 +1,19 @@ +<?php + +namespace App\Errors; + +use Matrix\Enums\ErrorCode; +use Symfony\Component\HttpFoundation\Response; + +class NotFoundError extends Exception +{ + public function __construct(string $message = "404") + { + parent::__construct(ErrorCode::NOT_FOUND, $message, Response::HTTP_NOT_FOUND); + } + + public function getAdditionalData(): array + { + return []; + } +} diff --git a/src/Errors/UnauthorizedError.php b/src/Errors/UnauthorizedError.php index cd9981f..e164597 100755 --- a/src/Errors/UnauthorizedError.php +++ b/src/Errors/UnauthorizedError.php @@ -7,9 +7,9 @@ use Symfony\Component\HttpFoundation\Response; class UnauthorizedError extends Exception { - public function __construct() + public function __construct(string $message = "Unauthorized") { - parent::__construct(ErrorCode::FORBIDDEN, "Unauthorized", Response::HTTP_UNAUTHORIZED); + parent::__construct(ErrorCode::FORBIDDEN, $message, Response::HTTP_UNAUTHORIZED); } public function getAdditionalData(): array 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 { } |
