Compare commits

..

No commits in common. "f788ba63ae963a66af4b9265331fc3d9c205aabd" and "982d348a3444055789d347020778f83e015c0f45" have entirely different histories.

20 changed files with 310 additions and 634 deletions

View File

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

View File

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

View File

@ -2,46 +2,51 @@
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 League\Config\Configuration; use Slim\Psr7\Factory\ResponseFactory;
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', realpath(__DIR__ . '/..')); define('APP_ROOT_DIR', __DIR__ . '/..');
define('APP_SRC_DIR', realpath(__DIR__)); define('APP_SRC_DIR', __DIR__);
define('APP_PUBLIC_DIR', realpath(__DIR__ . '/../public')); define('APP_PUBLIC_DIR', __DIR__ . '/../public');
define('APP_TEMPLATES_DIR', realpath(__DIR__ . '/../templates')); define('APP_TEMPLATES_DIR', __DIR__ . '/../templates');
define('APP_LOGS_DIR', realpath(__DIR__ . '/../var/logs')); define('APP_LOGS_DIR', __DIR__ . '/../var/logs');
define('APP_TEMP_DIR', realpath(__DIR__ . '/../var/temp')); define('APP_TEMP_DIR', __DIR__ . '/../var/temp');
class Bootstrap class Bootstrap
{ {
@ -54,14 +59,12 @@ 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",
@ -74,11 +77,10 @@ class Bootstrap
'name' => 'slovocast' 'name' => 'slovocast'
], ],
'database' => [ 'database' => [
'host' => $_ENV['DB_HOST'], 'host' => '127.0.0.1',
'database' => $_ENV['DB_SCHEMA'], 'database' => 'slovocast',
'username' => $_ENV['DB_USER'], 'username' => 'slovocast',
'password' => $_ENV['DB_PASSWORD'], 'password' => 'Password01',
'port' => (int) $_ENV['DB_PORT'],
] ]
]); ]);
@ -128,17 +130,16 @@ class Bootstrap
/** /**
* Database Connections * Database Connections
*/ */
ConnectionPoolInterface::class => function (ContainerInterface $container) { MysqlClient::class => function (ContainerInterface $container) {
$config = $container->get('config')->get('database'); $config = $container->get('config')->get('database');
$connectionString = sprintf(
$pool = new ConnectionPool(new ConnectionPoolConfig( "%s:%s@%s/%s",
$config['username'], rawurlencode($config['username']),
$config['password'], rawurlencode($config['password']),
$config['host'], $config['host'],
$config['database'] $config['database']
)); );
return new MysqlClient($connectionString);
return $pool;
}, },
/** /**
@ -153,7 +154,7 @@ class Bootstrap
*/ */
UserRepositoryInterface::class => function (ContainerInterface $container) { UserRepositoryInterface::class => function (ContainerInterface $container) {
return new UserRepository( return new UserRepository(
$container->get(ConnectionPoolInterface::class), $container->get(MysqlClient::class),
$container->get(UserAuthorizationInterface::class) $container->get(UserAuthorizationInterface::class)
); );
} }

View File

@ -2,21 +2,18 @@
namespace Slovocast\Controller; namespace Slovocast\Controller;
use Slovocast\Domain\Repository\Channel\ChannelRepositoryInterface; use Slovocast\Controller\Controller;
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 Slovocast\Infrastructure\Api\User\UserAuthorizationInterface; use Psr\Http\Message\ResponseInterface as Response;
use Odan\Session\SessionInterface;
class LoginUserAction extends Controller class LoginUserAction extends Controller
{ {

View File

@ -1,4 +1,4 @@
<?php /** @noinspection ALL */ <?php
namespace Slovocast\Domain\Repository\Channel; namespace Slovocast\Domain\Repository\Channel;
@ -95,11 +95,9 @@ class ChannelRepository implements ChannelRepositoryInterface
*/ */
public function create(Channel $channel, User $owner): int public function create(Channel $channel, User $owner): int
{ {
$query = <<<SQL $query = "INSERT INTO channels (name, slug, description, link,
INSERT INTO channels (name, slug, description, link,
language, copyright, explicit, owner_id) language, copyright, explicit, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
SQL;
$results = await($this->db->query($query, [ $results = await($this->db->query($query, [
$channel->getName(), $channel->getName(),
@ -122,16 +120,13 @@ SQL;
*/ */
public function update(Channel $channel): bool public function update(Channel $channel): bool
{ {
$query = <<<SQL $query = "UPDATE channels SET name = ?,
UPDATE channels SET name = ?,
slug = ?, slug = ?,
description = ?, description = ?,
link = ?, link = ?,
language = ?, language = ?,
copyright = ?, copyright = ?,
explicit = ? explicit = ?";
WHERE id = ?;
SQL;
$results = await($this->db->query($query, [ $results = await($this->db->query($query, [
$channel->getName(), $channel->getName(),
@ -141,7 +136,6 @@ SQL;
$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 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; use function React\Async\await;
use React\Mysql\MysqlClient;
use Slovocast\Infrastructure\User\UserAuthorizationInterface;
use Slovocast\Exception\EntityNotFoundException;
use Slovocast\Domain\Entity\User;
class UserRepository implements UserRepositoryInterface class UserRepository implements UserRepositoryInterface
{ {
public function __construct( public function __construct(
private ConnectionPoolInterface $connectionPool, private MysqlClient $db,
private UserAuthorizationInterface $userAuth private UserAuthorizationInterface $userAuth
) {} ) {}
@ -38,11 +38,7 @@ 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

@ -1,47 +0,0 @@
<?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

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

View File

@ -1,89 +0,0 @@
<?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

@ -1,45 +0,0 @@
<?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

@ -1,23 +0,0 @@
<?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\Api\User\UserAuthorizationInterface; use Slovocast\Infrastructure\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

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Slovocast\Infrastructure\Api\User; namespace Slovocast\Infrastructure\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

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,13 +44,7 @@ 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 'layouts/components/flash.twig' %} {% include 'components/flash.twig' %}
{% block content %}{% endblock %} {% block content %}{% endblock %}

View File

@ -1,16 +0,0 @@
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

@ -12,12 +12,5 @@ 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: