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"
DATABASE_HOST="localhost"
DATABASE_USER="slovocast"
DATABASE_PASSWORD="Password01"
DATABASE_SCHEMA="slovocast"
DATABASE_PORT=3306
DB_HOST="localhost"
DB_USER="slovocast"
DB_PASSWORD="Password01"
DB_SCHEMA="slovocast"
DB_PORT=3306

View File

@ -15,7 +15,8 @@
"dotenv-org/phpdotenv-vault": "^0.2.4",
"react/react": "^1.4",
"robmorgan/phinx": "^0.16.1",
"react/mysql": "^0.7dev"
"react/mysql": "^0.7dev",
"react/async": "^4.3"
},
"require-dev": {
"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
require_once '../vendor/autoload.php';
require_once realpath(__DIR__ . '/../vendor/autoload.php');
use Slovocast\Bootstrap;
use React\Http\HttpServer;
use React\Socket\SocketServer;
use Psr\Http\Message\ServerRequestInterface as Request;
try {
$app = Bootstrap::init();
$http = new HttpServer(fn (Request $request) => $app->handle($request));
$address = "127.0.0.1:8000";
$socket = new SocketServer($address);
$http->listen($socket);
$address = "0.0.0.0";
$port = "8000";
$socket = new SocketServer($address . ":" . $port);
echo "Server running at $address" . PHP_EOL;
$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);
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;
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\ContainerBuilder;
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 Exception;
use League\Config\Configuration;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Odan\Session\PhpSession;
use Odan\Session\SessionInterface;
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
*/
define('APP_ROOT_DIR', __DIR__ . '/..');
define('APP_SRC_DIR', __DIR__);
define('APP_PUBLIC_DIR', __DIR__ . '/../public');
define('APP_TEMPLATES_DIR', __DIR__ . '/../templates');
define('APP_LOGS_DIR', __DIR__ . '/../var/logs');
define('APP_TEMP_DIR', __DIR__ . '/../var/temp');
define('APP_ROOT_DIR', realpath(__DIR__ . '/..'));
define('APP_SRC_DIR', realpath(__DIR__));
define('APP_PUBLIC_DIR', realpath(__DIR__ . '/../public'));
define('APP_TEMPLATES_DIR', realpath(__DIR__ . '/../templates'));
define('APP_LOGS_DIR', realpath(__DIR__ . '/../var/logs'));
define('APP_TEMP_DIR', realpath(__DIR__ . '/../var/temp'));
class Bootstrap
{
@ -59,12 +54,14 @@ class Bootstrap
{
$config = new Configuration();
$dotenv = \DotenvVault\DotenvVault::createImmutable(APP_ROOT_DIR);
$dotenv->safeLoad();
// set all configuration details
$config->addSchema('site', SiteInformationSchema::getSchema());
$config->addSchema('database', DatabaseConnectionSchema::getSchema());
$config->addSchema('session', SessionSchema::getSchema());
$config->merge([
'site' => [
'name' => "Slovocast",
@ -77,10 +74,11 @@ class Bootstrap
'name' => 'slovocast'
],
'database' => [
'host' => '127.0.0.1',
'database' => 'slovocast',
'username' => 'slovocast',
'password' => 'Password01',
'host' => $_ENV['DB_HOST'],
'database' => $_ENV['DB_SCHEMA'],
'username' => $_ENV['DB_USER'],
'password' => $_ENV['DB_PASSWORD'],
'port' => (int) $_ENV['DB_PORT'],
]
]);
@ -130,16 +128,17 @@ class Bootstrap
/**
* Database Connections
*/
MysqlClient::class => function (ContainerInterface $container) {
ConnectionPoolInterface::class => function (ContainerInterface $container) {
$config = $container->get('config')->get('database');
$connectionString = sprintf(
"%s:%s@%s/%s",
rawurlencode($config['username']),
rawurlencode($config['password']),
$pool = new ConnectionPool(new ConnectionPoolConfig(
$config['username'],
$config['password'],
$config['host'],
$config['database']
);
return new MysqlClient($connectionString);
));
return $pool;
},
/**
@ -154,7 +153,7 @@ class Bootstrap
*/
UserRepositoryInterface::class => function (ContainerInterface $container) {
return new UserRepository(
$container->get(MysqlClient::class),
$container->get(ConnectionPoolInterface::class),
$container->get(UserAuthorizationInterface::class)
);
}

View File

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

View File

@ -2,12 +2,12 @@
namespace Slovocast\Controller\User;
use Odan\Session\SessionInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Slovocast\Controller\Controller;
use Slovocast\Domain\Repository\User\UserRepositoryInterface;
use Slovocast\Infrastructure\User\UserAuthorizationInterface;
use Slovocast\Exception\EntityNotFoundException;
use Psr\Http\Message\ResponseInterface as Response;
use Odan\Session\SessionInterface;
use Slovocast\Infrastructure\Api\User\UserAuthorizationInterface;
class LoginUserAction extends Controller
{

View File

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

View File

@ -2,16 +2,16 @@
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\Exception\EntityNotFoundException;
use Slovocast\Infrastructure\Api\Database\ConnectionPoolInterface;
use Slovocast\Infrastructure\Api\User\UserAuthorizationInterface;
use function React\Async\await;
class UserRepository implements UserRepositoryInterface
{
public function __construct(
private MysqlClient $db,
private ConnectionPoolInterface $connectionPool,
private UserAuthorizationInterface $userAuth
) {}
@ -38,7 +38,11 @@ class UserRepository implements UserRepositoryInterface
public function get(int $id): User
{
$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]);
}

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,6 +1,6 @@
<?php
namespace Slovocast\Infrastructure\User;
namespace Slovocast\Infrastructure\Api\User;
/**
* A simple interface for securing and checking secured passwords for a user

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;
use Slovocast\Infrastructure\User\UserAuthorizationInterface;
use Slovocast\Infrastructure\Api\User\UserAuthorizationInterface;
/**
* This empty class will essentially just check hashed passwords passed into it

View File

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

View File

@ -14,7 +14,7 @@
</header>
<main>
{% include 'components/flash.twig' %}
{% include 'layouts/components/flash.twig' %}
{% 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

@ -12,5 +12,12 @@ services:
- "3306:3306"
volumes:
- slovocast_db:/var/lib/mysql/data/
app:
build:
context: .
dockerfile: deploy/php/Dockerfile
ports:
- "8000:8000"
volumes:
slovocast_db: