summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--matrix-specification/Data/Capabilities.php44
-rw-r--r--matrix-specification/Data/Capability/BooleanCapability.php18
-rw-r--r--matrix-specification/Data/Capability/ProfileFieldsCapability.php26
-rw-r--r--matrix-specification/Data/Capability/RoomVersionsCapability.php23
-rw-r--r--matrix-specification/Data/PushRule.php2
-rw-r--r--matrix-specification/Enums/PushConditionKind.php1
-rw-r--r--migrations/20250819.php60
-rw-r--r--src/App.php9
-rw-r--r--src/Controllers/Client/ClientController.php392
-rw-r--r--src/Controllers/Client/KeyController.php119
-rw-r--r--src/Controllers/Client/MediaController.php23
-rwxr-xr-xsrc/Controllers/Client/RoomController.php45
-rwxr-xr-xsrc/Controllers/Client/UserController.php113
-rwxr-xr-xsrc/Errors/NotFoundError.php19
-rwxr-xr-xsrc/Errors/UnauthorizedError.php4
-rw-r--r--src/Models/RoomEvent.php2
-rw-r--r--src/Models/User.php27
-rw-r--r--src/Router.php2
-rw-r--r--src/Support/Logger.php11
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
{
}