diff options
author | Daniel Weipert <git@mail.dweipert.de> | 2025-08-19 15:50:42 +0200 |
---|---|---|
committer | Daniel Weipert <git@mail.dweipert.de> | 2025-08-19 16:11:52 +0200 |
commit | d08f4c83470c25d35d24594bd73e4effdac191a0 (patch) | |
tree | 8320e4d0750776891fa5680ce5904de714128fce | |
parent | a0ad1f5e7fac279b33ea09ca0e347cd7d02cd8ec (diff) |
database migrations and models for users, devices, tokens
-rwxr-xr-x | bin/db-migrate | 36 | ||||
-rw-r--r-- | docker-compose.yml | 7 | ||||
-rw-r--r-- | migrations/20250819.php | 45 | ||||
-rw-r--r-- | src/Controllers/LoginController.php | 73 | ||||
-rw-r--r-- | src/DB.php | 22 | ||||
-rw-r--r-- | src/Database.php | 36 | ||||
-rw-r--r-- | src/Errors/AppException.php | 11 | ||||
-rw-r--r-- | src/Models/Device.php | 150 | ||||
-rw-r--r-- | src/Models/Tokens.php | 41 | ||||
-rw-r--r-- | src/Models/User.php | 88 | ||||
-rw-r--r-- | src/Router/Router.php | 2 | ||||
-rw-r--r-- | src/Router/routes_client_server.php | 4 | ||||
-rw-r--r-- | src/Support/ConnectsToDatabase.php | 24 | ||||
-rw-r--r-- | src/Types/UserRegistrationKind.php | 9 |
14 files changed, 512 insertions, 36 deletions
diff --git a/bin/db-migrate b/bin/db-migrate new file mode 100755 index 0000000..784aadc --- /dev/null +++ b/bin/db-migrate @@ -0,0 +1,36 @@ +#!/bin/env php + +<?php + +use App\Database; +use Symfony\Component\Dotenv\Dotenv; + +require_once dirname(__DIR__) . "/vendor/autoload.php"; + +$dotenv = new Dotenv(); +$dotenv->load(dirname(__DIR__) . "/.env"); + +$migrationsPath = dirname(__DIR__) . "/migrations"; +$migrations = scandir($migrationsPath, SCANDIR_SORT_ASCENDING); + +$appliedMigrations = []; +try { + Database::getInstance()->query("select name from migrations")->fetchAll(); +} catch (\PDOException $exception) { + echo "migrations table doesn't exist yet."; +} + +foreach ($migrations as $migration) { + if (in_array($migration, [".", ".."])) { + continue; + } + + if (in_array($migration, $appliedMigrations)) { + continue; + } + + $path = "$migrationsPath/$migration"; + include $path; + + Database::getInstance()->query("insert into migrations (name) values (:name)", ["name" => $migration]); +} diff --git a/docker-compose.yml b/docker-compose.yml index 4ec2c2e..073a7c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,13 @@ services: volumes: - "./:/var/www/html" + adminer: + image: adminer + ports: + - "8081:8080" + environment: + - "ADMINER_DEFAULT_SERVER=db" + volumes: db: diff --git a/migrations/20250819.php b/migrations/20250819.php new file mode 100644 index 0000000..cfd0f65 --- /dev/null +++ b/migrations/20250819.php @@ -0,0 +1,45 @@ +<?php + +use App\Database; + +Database::getInstance()->query(<<<SQL + create table if not exists "migrations" ( + "id" bigserial primary key, + "name" varchar(64) not null + ); +SQL); + +Database::getInstance()->query(<<<SQL + create table if not exists "users" ( + "id" varchar(255) primary key, + "password" varchar(255), + + "name" varchar(255), + "is_deactivated" bool + ); +SQL); + +Database::getInstance()->query(<<<SQL + create table if not exists "devices" ( + "id" varchar(255) not null, + "name" varchar(255) not null, + + "user_id" varchar(255) references users(id) not null, + + primary key (user_id, id) + ); +SQL); + +Database::getInstance()->query(<<<SQL + create table if not exists "tokens" ( + "id" bigserial primary key, + + "access_token" varchar(255) not null, + "refresh_token" varchar(255) not null, + + "user_id" varchar(255) not null, + "device_id" varchar(255), + + foreign key (user_id, device_id) references devices(user_id, id) + ); +SQL); diff --git a/src/Controllers/LoginController.php b/src/Controllers/LoginController.php index d48628b..1ff234c 100644 --- a/src/Controllers/LoginController.php +++ b/src/Controllers/LoginController.php @@ -2,10 +2,15 @@ namespace App\Controllers; +use App\Database; +use App\Errors\AppException; +use App\Errors\ErrorCode; use App\Errors\UnknownError; -use App\Support\Parser; +use App\Models\Device; +use App\Models\User; use App\Types\LoginFlow; use App\Types\LoginType; +use App\Types\UserRegistrationKind; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; @@ -14,7 +19,6 @@ class LoginController { /** * GET /_matrix/client/r0/login - * GET /_matrix/client/v3/login */ public function supportedLoginTypes(): Response { @@ -31,28 +35,77 @@ class LoginController public function login(): Response { $request = Request::createFromGlobals(); - $content = json_decode($request->getContent(), true); + $body = json_decode($request->getContent(), true); // validate login type $loginType = null; try { - $loginType = LoginType::from($content["type"]); + $loginType = LoginType::from($body["type"]); } catch (\ValueError $error) { throw new UnknownError("Bad login type.", Response::HTTP_BAD_REQUEST); } - // get user name - $user = Parser::parseUser($content["identifier"]["user"]); + // get user id + $userId = $body["identifier"]["user"]; #if ($loginType == LoginType::PASSWORD) {} + $user = User::fetchWithPassword($userId, $body["password"]); + + if (! $user) { + throw new AppException(ErrorCode::FORBIDDEN, "Invalid credentials.", Response::HTTP_FORBIDDEN); + } + + $deviceId = $body["device_id"] ?? ""; + $device = $user->fetchDevice($deviceId); + + if (! $device) { + $device = Device::new( + $user->getId(), + initialDisplayName: $body["initial_device_display_name"] ?? "", + ); + } + return new JsonResponse([ - "access_token" => "abc123", - "device_id" => "ABC", + "access_token" => $device->getAccessToken(), + "device_id" => $device->getId(), "expires_in_ms" => 60000, - "refresh_token" => "def456", - "user_id" => "@{$user["username"]}:{$_ENV["DOMAIN"]}", + "refresh_token" => $device->getRefreshToken(), + "user_id" => $user->getId(), #"well_known" => [], ]); } + + /** + * POST /_matrix/client/v3/register + */ + public function register(): Response + { + $request = Request::createFromGlobals(); + $body = json_decode($request->getContent(), true); + + $kind = UserRegistrationKind::from($request->query->get("kind") ?? "user"); + + $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["initialDeviceDisplayName"] ?? ""; + + $device = Device::new($userId, $device_id, $initialDeviceDisplayName); + $device->insert(); + + return new JsonResponse([ + "access_token" => $device->getAccessToken(), + "device_id" => $device->getId(), + "expires_in_ms" => 60000, + "refresh_token" => $device->getRefreshToken(), + "user_id" => $userId, + ]); + } } diff --git a/src/DB.php b/src/DB.php deleted file mode 100644 index 53d078b..0000000 --- a/src/DB.php +++ /dev/null @@ -1,22 +0,0 @@ -<?php - -namespace App; - -class DB -{ - use Singleton; - - private \PDO $connection; - - public function __construct() - { - $driver = $_ENV['DB_DRIVER'] ?? 'pgsql'; - $host = $_ENV['DB_HOST'] ?? 'localhost'; - $port = $_ENV['DB_PORT'] ?? 5432; - $dbname = $_ENV['DB_NAME']; - $user = $_ENV['DB_USER']; - $password = $_ENV['DB_PASSWORD']; - - $this->connection = new \PDO("$driver:host=$host;port=$port;dbname=$dbname", $user, $password); - } -} diff --git a/src/Database.php b/src/Database.php new file mode 100644 index 0000000..3fff863 --- /dev/null +++ b/src/Database.php @@ -0,0 +1,36 @@ +<?php + +namespace App; + +use PDO; +use PDOStatement; + +class Database +{ + use Singleton; + + private \PDO $connection; + + public function __construct() + { + #$driver = $_ENV['DB_DRIVER'] ?? 'pgsql'; + $host = $_ENV['DB_HOST'] ?? 'localhost'; + $port = $_ENV['DB_PORT'] ?? 5432; + $dbname = $_ENV['DB_NAME']; + $user = $_ENV['DB_USER']; + $password = $_ENV['DB_PASSWORD']; + + $this->connection = new \PDO("pgsql:host=$host;port=$port;dbname=$dbname", $user, $password); + } + + /** + * @param array<mixed> $parameters + */ + public function query(string $query, array $parameters = []): \PDOStatement|false + { + $statement = $this->connection->prepare($query); + $statement->execute($parameters); + + return $statement; + } +} diff --git a/src/Errors/AppException.php b/src/Errors/AppException.php new file mode 100644 index 0000000..e4359e6 --- /dev/null +++ b/src/Errors/AppException.php @@ -0,0 +1,11 @@ +<?php + +namespace App\Errors; + +class AppException extends Exception +{ + public function getAdditionalData(): array + { + return []; + } +} diff --git a/src/Models/Device.php b/src/Models/Device.php new file mode 100644 index 0000000..2b09c6b --- /dev/null +++ b/src/Models/Device.php @@ -0,0 +1,150 @@ +<?php + +namespace App\Models; + +use App\Database; +use App\Support\ConnectsToDatabase; + +class Device implements ConnectsToDatabase +{ + public function __construct( + private string $id, + private string $userId, + private string $name + ) + {} + + /** + * @param array<string,mixed> $row The row from the database + */ + public static function fromDatabase(array $row): self + { + return new self( + $row["id"], + $row["user_id"], + $row["name"] + ); + } + + public static function fetch(string $id = "", string $userId = ""): ?self + { + if (! empty($id) && empty($userId)) { + throw new \InvalidArgumentException("Can't fetch device without user id."); + } + + $row = []; + + if (! empty($userId)) { + if (! empty($id)) { + $row = Database::getInstance()->query( + <<<SQL + select * from devices where id=:id and user_id=:user_id + SQL, + [ + "id" => $id, + "user_id" => $userId, + ] + )->fetch(); + } else { + $row = Database::getInstance()->query( + <<<SQL + select * from devices where user_id=:user_id + SQL, + [ + "user_id" => $userId, + ] + )->fetch(); + } + } + + if (empty($row)) { + return null; + } + + return self::fromDatabase($row); + } + + public static function fetchAll(string $userId = ""): array + { + if (empty($userId)) { + throw new \InvalidArgumentException("missing user id"); + } + + $devices = []; + $rows = Database::getInstance()->query( + <<<SQL + select * from devices + where user_id=:user_id + SQL, + [ + "user_id" => $userId, + ] + )->fetchAll(); + + foreach ($rows as $row) { + $devices[] = Device::fromDatabase($row); + } + + return $devices; + } + + public static function new(string $userId, string $deviceId = "", string $initialDisplayName = ""): self + { + $deviceId = $deviceId ?: md5($userId . random_bytes(512)); + $initialDisplayName = $initialDisplayName ?: "capybara"; + + $accessToken = md5($userId . random_bytes(512)); + $refreshToken = md5($userId . random_bytes(512)); + + return new self( + $deviceId, + $userId, + $initialDisplayName, + $accessToken, + $refreshToken, + ); + } + + public function insert(): bool + { + return Database::getInstance()->query( + <<<SQL + insert into devices (id, user_id, name, access_token, refresh_token) + values (:id, :user_id, :name, :access_token, :refresh_token) + SQL, + [ + "id" => $this->id, + "user_id" => $this->userId, + "name" => $this->name, + "access_token" => $this->accessToken, + "refresh_token" => $this->refreshToken, + ] + ); + } + + public function update(): bool + {} + + public function delete(): bool + {} + + public function getId(): string + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getAccessToken(): string + { + return $this->accessToken; + } + + public function getRefreshToken(): string + { + return $this->refreshToken; + } +} diff --git a/src/Models/Tokens.php b/src/Models/Tokens.php new file mode 100644 index 0000000..a94c876 --- /dev/null +++ b/src/Models/Tokens.php @@ -0,0 +1,41 @@ +<?php + +namespace App\Models; + +use App\Support\ConnectsToDatabase; + +class Tokens implements ConnectsToDatabase +{ + public function __construct( + private string $accessToken, + private string $refreshToken, + private string $userId, + private string $deviceId = "", + ) + {} + + public static function fromDatabase(array $row): self + { + return new self( + $row["access_token"], + $row["refresh_token"], + $row["user_id"], + $row["device_id"], + ); + } + + public static function fetch(): ?self + {} + + public static function fetchAll(): array + {} + + public function insert(): bool + {} + + public function update(): bool + {} + + public function delete(): bool + {} +} diff --git a/src/Models/User.php b/src/Models/User.php new file mode 100644 index 0000000..5d198e7 --- /dev/null +++ b/src/Models/User.php @@ -0,0 +1,88 @@ +<?php + +namespace App\Models; + +use App\Database; +use App\Support\ConnectsToDatabase; + +class User implements ConnectsToDatabase +{ + public function __construct(private string $id) + {} + + public static function fromDatabase(array $row): self + { + return new self( + $row["id"], + ); + } + + public static function fetch(string $id = ""): ?self + { + if (empty($id)) { + throw new \InvalidArgumentException("missing user id"); + } + + return Database::getInstance()->query( + <<<SQL + select * from users where id=:id + SQL, + [ + "id" => $id, + ] + ); + } + + public static function fetchAll(): array + {} + + public static function fetchWithPassword(string $id, string $password): ?self + { + $row = Database::getInstance()->query("select * from users where id=:id and password=:password", [ + "id" => $id, + "password" => $password, + ])->fetch(); + + if (empty($row)) { + return null; + } + + return self::fromDatabase($row); + } + + public function insert(): bool + { + return Database::getInstance()->query( + <<<SQL + insert into users (id) + values (:id) + SQL, + [ + "id" => $this->id, + ] + ); + } + + public function update(): bool + {} + + public function delete(): bool + {} + + public function getId(): string + { + return $this->id; + } + + public function fetchDevice(string $id): ?Device + { + return Device::fetch($id, $this->id); + } + /** + * @return Device[] + */ + public function fetchDevices(): array + { + return Device::fetchAll($this->id); + } +} diff --git a/src/Router/Router.php b/src/Router/Router.php index 1739e7c..61abed1 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -66,6 +66,8 @@ class Router return new ErrorResponse(ErrorCode::NOT_FOUND, "404", Response::HTTP_NOT_FOUND); } catch (MethodNotAllowedException $exception) { return new ErrorResponse(ErrorCode::FORBIDDEN, "403", Response::HTTP_FORBIDDEN); + } catch (\LogicException $exception) { // display logic exceptions normally + throw $exception; } catch (\Exception $exception) { return new ErrorResponse( ErrorCode::UNKNOWN, diff --git a/src/Router/routes_client_server.php b/src/Router/routes_client_server.php index 78e2e48..a0b117b 100644 --- a/src/Router/routes_client_server.php +++ b/src/Router/routes_client_server.php @@ -31,10 +31,6 @@ return function (RouteConfigurator $routes): void ->add("matrix_client_r0_login_types", "/_matrix/client/r0/login") ->controller($supportedLoginTypes) ->methods(["GET"]); - $routes - ->add("matrix_client_v3_login_types", "/_matrix/client/v3/login") - ->controller($supportedLoginTypes) - ->methods(["GET"]); $routes ->add("matrix_client_v3_login", "/_matrix/client/v3/login") diff --git a/src/Support/ConnectsToDatabase.php b/src/Support/ConnectsToDatabase.php new file mode 100644 index 0000000..29f566c --- /dev/null +++ b/src/Support/ConnectsToDatabase.php @@ -0,0 +1,24 @@ +<?php + +namespace App\Support; + +interface ConnectsToDatabase +{ + /** + * @param array<string,mixed> $row + */ + public static function fromDatabase(array $row): self; + + public static function fetch(): ?self; + + /** + * @return array<self> + */ + public static function fetchAll(): array; + + public function insert(): bool; + + public function update(): bool; + + public function delete(): bool; +} diff --git a/src/Types/UserRegistrationKind.php b/src/Types/UserRegistrationKind.php new file mode 100644 index 0000000..23dee2f --- /dev/null +++ b/src/Types/UserRegistrationKind.php @@ -0,0 +1,9 @@ +<?php + +namespace App\Types; + +enum UserRegistrationKind: string +{ + case USER = "user"; + case GUEST = "guest"; +} |