Skip to content

Commit

Permalink
Merge pull request #16 from WaterWolfDev/dev.waterwolf.club
Browse files Browse the repository at this point in the history
Fixes #14 -- Implement S3 for media uploads and database backups.
  • Loading branch information
Crinisus authored Apr 2, 2024
2 parents d7de46b + 3e3660c commit 75d6ec1
Show file tree
Hide file tree
Showing 33 changed files with 354 additions and 231 deletions.
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ RUN apk add --no-cache zip git curl bash \
caddy \
nodejs npm \
supercronic \
su-exec
su-exec \
mariadb-client \
restic

# Set up App user
RUN mkdir -p /var/app/www \
Expand Down
40 changes: 37 additions & 3 deletions backend/bootstrap/functions.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?php

use App\Environment;
use Symfony\Component\Filesystem\Filesystem;
/*
* Input Escaping
*/

function escapeHtml(?string $html): string
{
Expand All @@ -24,9 +25,42 @@ function escapeJs(mixed $string): string
}

/*
* Input Escaping
* User-uploaded media
*/

function mediaUrl(string $url): string
{
static $mediaBaseUrl;
if (!$mediaBaseUrl) {
$mediaBaseUrl = $_ENV['MEDIA_SITE_URL'] ?? null;
}

if (empty($mediaBaseUrl)) {
throw new \RuntimeException('Media base URL not configured.');
}

// Encode individual portions of the URL between slashes.
$url = implode("/", array_map("rawurlencode", explode("/", $url)));

return $mediaBaseUrl . '/' . ltrim($url, '/');
}

function avatarUrl(string|bool|null $userImg): string
{
return (!empty($userImg))
? mediaUrl('/img/profile/' . $userImg)
: '/static/img/avatar.webp';
}

function djAvatarUrl(
string|bool|null $djImg,
string|bool|null $userImg
): string {
return (!empty($djImg))
? mediaUrl('/img/djs/' . $djImg)
: avatarUrl($userImg);
}

function humanTime(string|int|null $timestamp = "", string $format = 'D, M d, Y \a\t g:i A'): string
{
if (empty($timestamp) || !is_numeric($timestamp)) {
Expand Down
30 changes: 16 additions & 14 deletions backend/bootstrap/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@
// Slim app
Slim\App::class => function (
ContainerInterface $di,
LoggerInterface $logger,
Environment $env
LoggerInterface $logger
) {
$httpFactory = new HttpFactory();

Expand All @@ -46,8 +45,8 @@
$routeCollector = $app->getRouteCollector();
$routeCollector->setDefaultInvocationStrategy(new RequestResponse());

if ($env->isProduction()) {
$routeCollector->setCacheFile($env->getTempDirectory() . '/app_routes.cache.php');
if (Environment::isProduction()) {
$routeCollector->setCacheFile(Environment::getTempDirectory() . '/app_routes.cache.php');
}

call_user_func(include(__DIR__ . '/routes.php'), $app);
Expand All @@ -62,7 +61,7 @@

// Add an error handler for most in-controller/task situations.
$errorHandler = $app->addErrorMiddleware(
$env->isDev(),
Environment::isDev(),
true,
true,
$logger
Expand All @@ -87,6 +86,7 @@
$commandLoader = new ContainerCommandLoader(
$di,
[
'backup' => App\Console\Command\BackupCommand::class,
'init' => App\Console\Command\InitCommand::class,
'migrate' => App\Console\Command\MigrateCommand::class,
'seed' => App\Console\Command\SeedCommand::class,
Expand All @@ -104,9 +104,9 @@
Escaper::class => static fn() => new Escaper('utf-8'),

// Database Abstraction Layer
Connection::class => static function (Environment $env) {
Connection::class => static function () {
$connectionParams = [
...$env->getDatabaseInfo(),
...Environment::getDatabaseInfo(),
'driver' => 'pdo_mysql',
'options' => [
\PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'UTF8MB4' COLLATE 'utf8mb4_unicode_ci'",
Expand All @@ -120,8 +120,11 @@
ImageManager::class => static fn() => new ImageManager(new ImageManagerGdDriver()),

// E-mail delivery service
Mailer::class => static function (Environment $env) {
$dsn = MailerDsn::fromString($env->getMailerDsn());
Mailer::class => static function () {
$dsnString = $_ENV['MAILER_DSN']
?? throw new \RuntimeException('Mailer not configured.');

$dsn = MailerDsn::fromString($dsnString);
$transport = (new SesTransportFactory())->create($dsn);

return new Mailer($transport);
Expand All @@ -139,11 +142,10 @@

// PSR-6 cache
CacheItemPoolInterface::class => static function (
Logger $logger,
Environment $env
Logger $logger
) {
$cacheInterface = new FilesystemAdapter(
directory: $env->getTempDirectory() . '/cache'
directory: Environment::getTempDirectory() . '/cache'
);
$cacheInterface->setLogger($logger);
return $cacheInterface;
Expand All @@ -153,10 +155,10 @@
CacheInterface::class => static fn(CacheItemPoolInterface $psr6Cache) => new Psr16Cache($psr6Cache),

// PSR Logger
Logger::class => function (Environment $env) {
Logger::class => function () {
$logger = new Logger('site');

$loggingLevel = $env->isProduction()
$loggingLevel = Environment::isProduction()
? LogLevel::Warning
: LogLevel::Debug;

Expand Down
45 changes: 14 additions & 31 deletions backend/src/AppFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,34 @@

final class AppFactory
{
public static function createApp(
array $appEnvironment = []
): SlimApp {
$environment = self::buildEnvironment($appEnvironment);
$di = self::buildContainer($environment);
public static function createApp(): SlimApp
{
self::applyPhpSettings();
$di = self::buildContainer();
return $di->get(SlimApp::class);
}

public static function createCli(
array $appEnvironment = []
): ConsoleApplication {
$environment = self::buildEnvironment($appEnvironment);
$di = self::buildContainer($environment);
public static function createCli(): ConsoleApplication
{
self::applyPhpSettings();
$di = self::buildContainer();

// Some CLI commands require the App to be injected for routing.
$di->get(SlimApp::class);

return $di->get(ConsoleApplication::class);
}

public static function buildContainer(Environment $environment): ContainerInterface
public static function buildContainer(): ContainerInterface
{
$containerBuilder = new ContainerBuilder();
$containerBuilder->useAutowiring(true);
$containerBuilder->useAttributes(true);

if ($environment->isProduction()) {
$containerBuilder->enableCompilation($environment->getTempDirectory());
if (Environment::isProduction()) {
$containerBuilder->enableCompilation(Environment::getTempDirectory());
}

$containerBuilder->addDefinitions([
Environment::class => $environment,
]);
$containerBuilder->addDefinitions(dirname(__DIR__) . '/bootstrap/services.php');

$di = $containerBuilder->build();
Expand All @@ -59,29 +54,17 @@ public static function buildContainer(Environment $environment): ContainerInterf
return $di;
}

/**
* @param array<string, mixed> $rawEnvironment
*/
public static function buildEnvironment(array $rawEnvironment = []): Environment
private static function applyPhpSettings(): void
{
$_ENV = getenv();
$rawEnvironment = array_merge(array_filter($_ENV), $rawEnvironment);
$environment = new Environment($rawEnvironment);

self::applyPhpSettings($environment);

return $environment;
}

private static function applyPhpSettings(Environment $environment): void
{
error_reporting(
$environment->isProduction()
Environment::isProduction()
? E_ALL & ~E_NOTICE & ~E_WARNING & ~E_STRICT & ~E_DEPRECATED
: E_ALL & ~E_NOTICE
);

$displayStartupErrors = (!$environment->isProduction() || $environment->isCli())
$displayStartupErrors = (!Environment::isProduction() || Environment::isCli())
? '1'
: '0';
ini_set('display_startup_errors', $displayStartupErrors);
Expand Down
110 changes: 110 additions & 0 deletions backend/src/Console/Command/BackupCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

namespace App\Console\Command;

use App\Environment;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;

#[AsCommand(
name: 'backup',
description: 'Store a backup of the current database.',
)]
final class BackupCommand extends AbstractCommand
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Backup Database');

$tempDir = Environment::getTempDirectory() . '/db';

$fsUtils = new Filesystem();
$fsUtils->remove($tempDir);
$fsUtils->mkdir($tempDir);

$path = $tempDir . '/db.sql';

$this->dumpDatabase($io, $path);

if (Environment::isProduction()) {
$this->passThruProcess(
$io,
'restic --verbose backup ' . $tempDir,
$tempDir
);
}

$io->success('DB Backup Complete: File available at ' . $path);
return 0;
}

protected function dumpDatabase(
SymfonyStyle $io,
string $path
): void {
$connSettings = Environment::getDatabaseInfo();
$commandEnvVars = [
'DB_HOST' => $connSettings['host'],
'DB_DATABASE' => $connSettings['dbname'],
'DB_USERNAME' => $connSettings['user'],
'DB_PASSWORD' => $connSettings['password'],
'DB_DEST' => $path,
];

$commandFlags = [
'--host=$DB_HOST',
'--user=$DB_USERNAME',
'--password=$DB_PASSWORD',
'--add-drop-table',
'--default-character-set=UTF8MB4',
];

$this->passThruProcess(
$io,
'mariadb-dump ' . implode(' ', $commandFlags) . ' $DB_DATABASE > $DB_DEST',
dirname($path),
$commandEnvVars
);
}

protected function passThruProcess(
SymfonyStyle $io,
string|array $cmd,
?string $cwd = null,
array $env = [],
int $timeout = 14400
): Process {
set_time_limit($timeout);

if (is_array($cmd)) {
$process = new Process($cmd, $cwd);
} else {
$process = Process::fromShellCommandline($cmd, $cwd);
}

$process->setTimeout($timeout - 60);
$process->setIdleTimeout(null);

$stdout = [];
$stderr = [];

$process->mustRun(function ($type, $data) use ($process, $io, &$stdout, &$stderr): void {
if ($process::ERR === $type) {
$io->getErrorStyle()->write($data);
$stderr[] = $data;
} else {
$io->write($data);
$stdout[] = $data;
}
}, $env);

return $process;
}
}
8 changes: 1 addition & 7 deletions backend/src/Console/Command/MigrateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,6 @@
#[AsCommand('migrate', 'Update the database to the latest migration version.')]
final class MigrateCommand extends AbstractCommand
{
public function __construct(
private readonly Environment $environment
) {
parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$phinx = new PhinxApplication();
Expand All @@ -26,7 +20,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$arguments = [
'command' => 'migrate',
'--environment' => 'db',
'--configuration' => $this->environment->getBaseDirectory() . '/phinx.php',
'--configuration' => Environment::getBaseDirectory() . '/phinx.php',
];

return $command->run(new ArrayInput($arguments), $output);
Expand Down
5 changes: 2 additions & 3 deletions backend/src/Console/Command/SeedCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
final class SeedCommand extends AbstractCommand
{
public function __construct(
private readonly Environment $environment,
private readonly Connection $db
) {
parent::__construct();
Expand All @@ -25,7 +24,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

if (!$this->environment->isDev()) {
if (!Environment::isDev()) {
$io->error('This can only be used in development mode.');
return 1;
}
Expand All @@ -48,7 +47,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$arguments = [
'command' => 'seed:run',
'--environment' => 'db',
'--configuration' => $this->environment->getBaseDirectory() . '/phinx.php',
'--configuration' => Environment::getBaseDirectory() . '/phinx.php',
];

return $command->run(new ArrayInput($arguments), $output);
Expand Down
Loading

0 comments on commit 75d6ec1

Please sign in to comment.