Compare commits

..

10 Commits

20 changed files with 634 additions and 310 deletions

View File

@ -1,6 +1,7 @@
APP_PORT=8000
DEPLOYMENT_ENV="development" DEPLOYMENT_ENV="development"
DATABASE_HOST="localhost" DB_HOST="localhost"
DATABASE_USER="slovocast" DB_USER="slovocast"
DATABASE_PASSWORD="Password01" DB_PASSWORD="Password01"
DATABASE_SCHEMA="slovocast" DB_SCHEMA="slovocast"
DATABASE_PORT=3306 DB_PORT=3306

View File

@ -15,7 +15,8 @@
"dotenv-org/phpdotenv-vault": "^0.2.4", "dotenv-org/phpdotenv-vault": "^0.2.4",
"react/react": "^1.4", "react/react": "^1.4",
"robmorgan/phinx": "^0.16.1", "robmorgan/phinx": "^0.16.1",
"react/mysql": "^0.7dev" "react/mysql": "^0.7dev",
"react/async": "^4.3"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11.1", "phpunit/phpunit": "^11.1",

494
app/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,34 @@
<?php <?php
require_once '../vendor/autoload.php'; require_once realpath(__DIR__ . '/../vendor/autoload.php');
use Slovocast\Bootstrap; use Slovocast\Bootstrap;
use React\Http\HttpServer; use React\Http\HttpServer;
use React\Socket\SocketServer; use React\Socket\SocketServer;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
$app = Bootstrap::init(); try {
$app = Bootstrap::init();
$http = new HttpServer(fn (Request $request) => $app->handle($request));
$address = "0.0.0.0";
$port = "8000";
$socket = new SocketServer($address . ":" . $port);
$http = new HttpServer(fn (Request $request) => $app->handle($request)); $http->on('error', function ($error) use ($socket) {
$address = "127.0.0.1:8000"; fprintf(STDERR, $error->getMessage() . "\n");
$socket = new SocketServer($address); fprintf(STDERR, "Closing socket connection.\n");
$http->listen($socket); $socket->close();
});
echo "Server running at $address" . PHP_EOL; $http->on('exit', function () use ($socket) {
fprintf(STDOUT, "Closing socket connection.\n");
$socket->close();
});
$http->listen($socket);
fprintf(STDOUT, "Server running on %s\n", $address);
} catch (\Exception $e) {
fprintf(STDERR, "Error caught bootstrapping the application\n");
fprintf(STDERR, $e->getMessage() . "\n");
return -1;
}

View File

@ -2,51 +2,46 @@
namespace Slovocast; namespace Slovocast;
use Exception;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Slim\App;
use Slim\Factory\AppFactory;
use League\Config\Configuration;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use DI\Container; use DI\Container;
use DI\ContainerBuilder; use DI\ContainerBuilder;
use Exception;
use Slim\Psr7\Factory\ResponseFactory; use League\Config\Configuration;
use Slovocast\Configuration\SiteInformationSchema;
use Slovocast\Configuration\DatabaseConnectionSchema;
use Slovocast\Configuration\SessionSchema;
use Twig\Error\LoaderError;
use Slovocast\Domain\Repository\User\UserRepositoryInterface;
use Slovocast\Domain\Repository\User\UserRepository;
use Slovocast\Infrastructure\User\UserAuthorizationInterface;
use Slovocast\Infrastructure\User\BasicUserAuthorization;
use React\Mysql\MysqlClient;
use Psr\Log\LoggerInterface;
use Monolog\Logger;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Monolog\Level; use Monolog\Level;
use Monolog\Logger;
use Odan\Session\PhpSession; use Odan\Session\PhpSession;
use Odan\Session\SessionInterface; use Odan\Session\SessionInterface;
use Odan\Session\SessionManagerInterface; use Odan\Session\SessionManagerInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Log\LoggerInterface;
use React\Mysql\MysqlClient;
use Slim\App;
use Slim\Factory\AppFactory;
use Slim\Psr7\Factory\ResponseFactory;
use Slovocast\Configuration\DatabaseConnectionSchema;
use Slovocast\Configuration\SessionSchema;
use Slovocast\Configuration\SiteInformationSchema;
use Slovocast\Domain\Repository\User\UserRepository;
use Slovocast\Domain\Repository\User\UserRepositoryInterface;
use Slovocast\Infrastructure\Api\Database\ConnectionPoolInterface;
use Slovocast\Infrastructure\Api\User\UserAuthorizationInterface;
use Slovocast\Infrastructure\Database\ConnectionPoolConfig;
use Slovocast\Infrastructure\Database\ConnectionPool;
use Slovocast\Infrastructure\User\BasicUserAuthorization;
use Twig\Error\LoaderError;
/** /**
* Defines here are used globally * Defines here are used globally
*/ */
define('APP_ROOT_DIR', __DIR__ . '/..'); define('APP_ROOT_DIR', realpath(__DIR__ . '/..'));
define('APP_SRC_DIR', __DIR__); define('APP_SRC_DIR', realpath(__DIR__));
define('APP_PUBLIC_DIR', __DIR__ . '/../public'); define('APP_PUBLIC_DIR', realpath(__DIR__ . '/../public'));
define('APP_TEMPLATES_DIR', __DIR__ . '/../templates'); define('APP_TEMPLATES_DIR', realpath(__DIR__ . '/../templates'));
define('APP_LOGS_DIR', __DIR__ . '/../var/logs'); define('APP_LOGS_DIR', realpath(__DIR__ . '/../var/logs'));
define('APP_TEMP_DIR', __DIR__ . '/../var/temp'); define('APP_TEMP_DIR', realpath(__DIR__ . '/../var/temp'));
class Bootstrap class Bootstrap
{ {
@ -59,12 +54,14 @@ class Bootstrap
{ {
$config = new Configuration(); $config = new Configuration();
$dotenv = \DotenvVault\DotenvVault::createImmutable(APP_ROOT_DIR); $dotenv = \DotenvVault\DotenvVault::createImmutable(APP_ROOT_DIR);
$dotenv->safeLoad();
// set all configuration details // set all configuration details
$config->addSchema('site', SiteInformationSchema::getSchema()); $config->addSchema('site', SiteInformationSchema::getSchema());
$config->addSchema('database', DatabaseConnectionSchema::getSchema()); $config->addSchema('database', DatabaseConnectionSchema::getSchema());
$config->addSchema('session', SessionSchema::getSchema()); $config->addSchema('session', SessionSchema::getSchema());
$config->merge([ $config->merge([
'site' => [ 'site' => [
'name' => "Slovocast", 'name' => "Slovocast",
@ -77,10 +74,11 @@ class Bootstrap
'name' => 'slovocast' 'name' => 'slovocast'
], ],
'database' => [ 'database' => [
'host' => '127.0.0.1', 'host' => $_ENV['DB_HOST'],
'database' => 'slovocast', 'database' => $_ENV['DB_SCHEMA'],
'username' => 'slovocast', 'username' => $_ENV['DB_USER'],
'password' => 'Password01', 'password' => $_ENV['DB_PASSWORD'],
'port' => (int) $_ENV['DB_PORT'],
] ]
]); ]);
@ -130,16 +128,17 @@ class Bootstrap
/** /**
* Database Connections * Database Connections
*/ */
MysqlClient::class => function (ContainerInterface $container) { ConnectionPoolInterface::class => function (ContainerInterface $container) {
$config = $container->get('config')->get('database'); $config = $container->get('config')->get('database');
$connectionString = sprintf(
"%s:%s@%s/%s", $pool = new ConnectionPool(new ConnectionPoolConfig(
rawurlencode($config['username']), $config['username'],
rawurlencode($config['password']), $config['password'],
$config['host'], $config['host'],
$config['database'] $config['database']
); ));
return new MysqlClient($connectionString);
return $pool;
}, },
/** /**
@ -154,7 +153,7 @@ class Bootstrap
*/ */
UserRepositoryInterface::class => function (ContainerInterface $container) { UserRepositoryInterface::class => function (ContainerInterface $container) {
return new UserRepository( return new UserRepository(
$container->get(MysqlClient::class), $container->get(ConnectionPoolInterface::class),
$container->get(UserAuthorizationInterface::class) $container->get(UserAuthorizationInterface::class)
); );
} }

View File

@ -2,18 +2,21 @@
namespace Slovocast\Controller; namespace Slovocast\Controller;
use Slovocast\Controller\Controller; use Slovocast\Domain\Repository\Channel\ChannelRepositoryInterface;
use Slovocast\Domain\Repository\User\UserRepositoryInterface; use Slovocast\Domain\Repository\User\UserRepositoryInterface;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
class DashboardPage extends Controller class DashboardPage extends Controller
{ {
public function __construct( public function __construct(
protected UserRepositoryInterface $userRepository protected UserRepositoryInterface $userRepository,
protected ChannelRepositoryInterface $channelRepository
) { } ) { }
public function handle(): Response public function handle(): Response
{ {
// get the user details
// get the channels
return $this->render('dashboard.twig'); return $this->render('dashboard.twig');
} }
} }

View File

@ -2,12 +2,12 @@
namespace Slovocast\Controller\User; namespace Slovocast\Controller\User;
use Odan\Session\SessionInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Slovocast\Controller\Controller; use Slovocast\Controller\Controller;
use Slovocast\Domain\Repository\User\UserRepositoryInterface; use Slovocast\Domain\Repository\User\UserRepositoryInterface;
use Slovocast\Infrastructure\User\UserAuthorizationInterface;
use Slovocast\Exception\EntityNotFoundException; use Slovocast\Exception\EntityNotFoundException;
use Psr\Http\Message\ResponseInterface as Response; use Slovocast\Infrastructure\Api\User\UserAuthorizationInterface;
use Odan\Session\SessionInterface;
class LoginUserAction extends Controller class LoginUserAction extends Controller
{ {
@ -34,7 +34,7 @@ class LoginUserAction extends Controller
} }
// start the session // start the session
$this->session->set('user', [ $this->session->set('user', [
'id' => $user->getId(), 'id' => $user->getId(),
'authenticated' => true 'authenticated' => true
]); ]);

View File

@ -1,4 +1,4 @@
<?php <?php /** @noinspection ALL */
namespace Slovocast\Domain\Repository\Channel; namespace Slovocast\Domain\Repository\Channel;
@ -95,9 +95,11 @@ class ChannelRepository implements ChannelRepositoryInterface
*/ */
public function create(Channel $channel, User $owner): int public function create(Channel $channel, User $owner): int
{ {
$query = "INSERT INTO channels (name, slug, description, link, $query = <<<SQL
language, copyright, explicit, owner_id) INSERT INTO channels (name, slug, description, link,
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; language, copyright, explicit, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
SQL;
$results = await($this->db->query($query, [ $results = await($this->db->query($query, [
$channel->getName(), $channel->getName(),
@ -120,13 +122,16 @@ class ChannelRepository implements ChannelRepositoryInterface
*/ */
public function update(Channel $channel): bool public function update(Channel $channel): bool
{ {
$query = "UPDATE channels SET name = ?, $query = <<<SQL
slug = ?, UPDATE channels SET name = ?,
description = ?, slug = ?,
link = ?, description = ?,
language = ?, link = ?,
copyright = ?, language = ?,
explicit = ?"; copyright = ?,
explicit = ?
WHERE id = ?;
SQL;
$results = await($this->db->query($query, [ $results = await($this->db->query($query, [
$channel->getName(), $channel->getName(),
@ -136,6 +141,7 @@ class ChannelRepository implements ChannelRepositoryInterface
$channel->getLanguage(), $channel->getLanguage(),
$channel->getCopyright(), $channel->getCopyright(),
$channel->isExplicit(), $channel->isExplicit(),
$channel->getId(),
])); ]));
return (bool) $results->affectedRows; return (bool) $results->affectedRows;

View File

@ -2,16 +2,16 @@
namespace Slovocast\Domain\Repository\User; namespace Slovocast\Domain\Repository\User;
use function React\Async\await;
use React\Mysql\MysqlClient;
use Slovocast\Infrastructure\User\UserAuthorizationInterface;
use Slovocast\Exception\EntityNotFoundException;
use Slovocast\Domain\Entity\User; use Slovocast\Domain\Entity\User;
use Slovocast\Exception\EntityNotFoundException;
use Slovocast\Infrastructure\Api\Database\ConnectionPoolInterface;
use Slovocast\Infrastructure\Api\User\UserAuthorizationInterface;
use function React\Async\await;
class UserRepository implements UserRepositoryInterface class UserRepository implements UserRepositoryInterface
{ {
public function __construct( public function __construct(
private MysqlClient $db, private ConnectionPoolInterface $connectionPool,
private UserAuthorizationInterface $userAuth private UserAuthorizationInterface $userAuth
) {} ) {}
@ -38,7 +38,11 @@ class UserRepository implements UserRepositoryInterface
public function get(int $id): User public function get(int $id): User
{ {
$query = "SELECT * FROM users WHERE id = ? LIMIT 1"; $query = "SELECT * FROM users WHERE id = ? LIMIT 1";
$results = await($this->db->query($query, [ $id ]));
/** @var $conn ConnectionPool */
$conn = $this->connectionPool->getConnection();
$results = await($conn->query($query, [ $id ]));
return $this->userFromQueryResults($results->resultRows[0]); return $this->userFromQueryResults($results->resultRows[0]);
} }

View File

@ -0,0 +1,47 @@
<?php
namespace Slovocast\Infrastructure\Api\Database;
use Exception;
interface ConnectionPoolInterface
{
public function getTotalIdleConnections(): int;
public function getTotalActiveConnections(): int;
/**
* If there is at least one idle connection waiting to be used.
*
* @return bool
*/
public function hasIdleConnection(): bool;
/**
* Sets the new wait timeout for acquiring a connection.
*
* @param int $ms The amount of time in seconds.
*/
public function setWaitTimeout(int $s): void;
public function getWaitTimeout(): int;
public function getConnectionLimit(): int;
/**
* This method will grab a connection from the Idle list, push it into the Active list and returns that same
* connection to the caller. If there is no active connections, the method will wait based on the `waitTimeout`
* value (in seconds) and throw an exception when the timeout has been reached.
*
* @throws Exception When the Wait Timeout is surpassed waiting for an idle connection
* @return PooledConnectionInterface
*/
public function getConnection(): PooledConnectionInterface;
/**
* This method will return a connection from the Active list into the Idle list.
*
* @param PooledConnectionInterface $connection
* @return void
*/
public function releaseConnection(PooledConnectionInterface $connection): void;
}

View File

@ -0,0 +1,8 @@
<?php
namespace Slovocast\Infrastructure\Api\Database;
interface PooledConnectionInterface
{
public function release(): void;
}

View File

@ -1,11 +1,11 @@
<?php <?php
namespace Slovocast\Infrastructure\User; namespace Slovocast\Infrastructure\Api\User;
/** /**
* A simple interface for securing and checking secured passwords for a user * A simple interface for securing and checking secured passwords for a user
*/ */
interface UserAuthorizationInterface interface UserAuthorizationInterface
{ {
public function hash(string $password): string; public function hash(string $password): string;
public function verify(string $password, string $hash): bool; public function verify(string $password, string $hash): bool;

View File

@ -0,0 +1,89 @@
<?php
namespace Slovocast\Infrastructure\Database;
use Slovocast\Infrastructure\Api\Database\ConnectionPoolInterface;
use Slovocast\Infrastructure\Api\Database\PooledConnectionInterface;
use SplObjectStorage;
class ConnectionPool implements ConnectionPoolInterface
{
private int $waitTimeout;
private SplObjectStorage $idleConnections;
private SplObjectStorage $activeConnections;
private ConnectionPoolConfig $config;
public function __construct(ConnectionPoolConfig $config)
{
$this->idleConnections = new SplObjectStorage();
$this->activeConnections = new SplObjectStorage();
for ($i = 0; $i < $this->config->getTotalConnections(); $i++) {
$pooledConnection = new PooledConnection($this->config->getDsnString());
$this->idleConnections->attach($$pooledConnection);
}
$this->waitTimeout = $config->getPoolWaitTimeout();
}
public function getTotalIdleConnections(): int
{
return $this->idleConnections->count();
}
public function getTotalActiveConnections(): int
{
return $this->activeConnections->count();
}
public function hasIdleConnection(): bool
{
return $this->idleConnections->count() > 0;
}
public function setWaitTimeout(int $s): void
{
$this->waitTimeout = $s;
}
public function getWaitTimeout(): int
{
return $this->waitTimeout;
}
public function getConnectionLimit(): int
{
return $this->config->getTotalConnections();
}
/**
* @TODO Throw an exception when a total timeout is exceeded. We do not want
* to get into an infinite loop.
*
* @return PooledConnectionInterface
*/
public function getConnection(): PooledConnectionInterface
{
if (!$this->hasIdleConnection()) {
\React\Async\delay((float) $this->getWaitTimeout());
return $this->getConnection();
}
// Remove from the idle pool
$conn = $this->idleConnections->current();
$this->idleConnections->detach($conn);
// Attach to the pool to the connectin, add it to the active pool
$conn->setConnectionPool($this);
$this->activeConnections->attach($conn);
return $conn;
}
public function releaseConnection(PooledConnectionInterface $connection): void
{
if ($this->activeConnections->contains($connection)) {
$this->activeConnections->detach($connection);
$this->idleConnections->attach($connection);
}
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Slovocast\Infrastructure\Database;
class ConnectionPoolConfig
{
const DEFAULT_WAIT_TIMEOUT = 60;
const DEFAULT_TOTAL_CONNECTIONS = 10;
public function __construct(
public readonly string $username,
public readonly string $password,
public readonly string $database,
public readonly string $host,
protected int $port = 3306,
protected int $poolWaitTimeout = self::DEFAULT_WAIT_TIMEOUT,
protected int $totalConnections = self::DEFAULT_TOTAL_CONNECTIONS
) { }
public function getPort(): int
{
return $this->port;
}
public function getPoolWaitTimeout(): int
{
return $this->poolWaitTimeout;
}
public function getTotalConnections(): int
{
return $this->totalConnections;
}
public function getDsnString(): string
{
return sprintf(
"%s:%s@%s/%s",
rawurlencode($this->username),
rawurlencode($this->password),
$this->host,
$this->database
);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Slovocast\Infrastructure\Database;
use React\Mysql\MysqlClient;
use Slovocast\Infrastructure\Api\Database\ConnectionPoolInterface;
use Slovocast\Infrastructure\Api\Database\PooledConnectionInterface;
class PooledConnection extends MysqlClient implements PooledConnectionInterface
{
protected ConnectionPoolInterface $connectionPool;
public function setConnectionPool(ConnectionPoolInterface $connectionPool): self
{
$this->connectionPool = $connectionPool;
return $this;
}
public function release(): void
{
$this->connectionPool->releaseConnection($this);
}
}

View File

@ -2,7 +2,7 @@
namespace Slovocast\Infrastructure\User; namespace Slovocast\Infrastructure\User;
use Slovocast\Infrastructure\User\UserAuthorizationInterface; use Slovocast\Infrastructure\Api\User\UserAuthorizationInterface;
/** /**
* This empty class will essentially just check hashed passwords passed into it * This empty class will essentially just check hashed passwords passed into it
@ -15,7 +15,7 @@ class BasicUserAuthorization implements UserAuthorizationInterface
return password_hash($password, PASSWORD_BCRYPT); return password_hash($password, PASSWORD_BCRYPT);
} }
public function verify(string $password, string $hash): bool public function verify(string $password, string $hash): bool
{ {
return password_verify($password, $hash); return password_verify($password, $hash);
} }

View File

@ -25,11 +25,11 @@ class Routes
$app->get('/healthcheck', HealthCheck::class); $app->get('/healthcheck', HealthCheck::class);
// User Routes // User Routes
self::users($app); self::users($app);
self::dashboard($app);
} }
/** /**
* @param App $app Instantiated Application * @param App $app Instantiated Application
* @return void
*/ */
protected static function users(App $app): void protected static function users(App $app): void
{ {
@ -44,7 +44,13 @@ class Routes
$app->post('/login', LoginUserAction::class) $app->post('/login', LoginUserAction::class)
->add(AuthenticatedMiddleware::class) ->add(AuthenticatedMiddleware::class)
->setName('user-login-action'); ->setName('user-login-action');
}
/**
* @param App $app Instance of the application
*/
protected static function dashboard(App $app): void
{
$app->get('/dashboard', DashboardPage::class) $app->get('/dashboard', DashboardPage::class)
->add(AuthenticatedMiddleware::class) ->add(AuthenticatedMiddleware::class)
->setName('user-dashboard'); ->setName('user-dashboard');

View File

@ -14,7 +14,7 @@
</header> </header>
<main> <main>
{% include 'components/flash.twig' %} {% include 'layouts/components/flash.twig' %}
{% block content %}{% endblock %} {% block content %}{% endblock %}

16
deploy/php/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
ARG PHP_VERSION=8.3
FROM chialab/php:${PHP_VERSION}
WORKDIR /app
COPY app .
RUN composer install \
--ignore-platform-reqs \
--no-interaction \
--no-plugins \
--prefer-dist \
--no-progress \
--optimize-autoloader
EXPOSE 8000
CMD [ "php", "server/server.php" ]

View File

@ -2,7 +2,7 @@ version: '3.9'
services: services:
database: database:
image: mariadb:latest image: mariadb:latest
environment: environment:
MARIADB_DATABASE: "slovocast" MARIADB_DATABASE: "slovocast"
MARIADB_USER: "slovocast" MARIADB_USER: "slovocast"
@ -12,5 +12,12 @@ services:
- "3306:3306" - "3306:3306"
volumes: volumes:
- slovocast_db:/var/lib/mysql/data/ - slovocast_db:/var/lib/mysql/data/
app:
build:
context: .
dockerfile: deploy/php/Dockerfile
ports:
- "8000:8000"
volumes: volumes:
slovocast_db: slovocast_db: