Initial code commit

This commit is contained in:
darcy (iris system) 2021-08-23 22:34:10 +12:00
parent 3eff0536c9
commit 0d1c3e63d8
12 changed files with 4356 additions and 0 deletions

32
bootstrap.php Normal file
View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
define('IX_ENVBASE', 'SITE');
define('IX_BASE', dirname(__FILE__));
require_once(IX_BASE . '/vendor/autoload.php');
use ix\HookMachine;
use ix\Container\Container;
use ix\Controller\Controller;
use ix\Application\Application;
/* Language initialization */
(new \i18n(IX_BASE . '/lang/{LANGUAGE}.ini', IX_BASE . '/cache/lang', 'en'))->init();
/* Container hooks */
HookMachine::add([Container::class, 'construct'], '\ix\Container\ContainerHooksHtmlRenderer::hookContainerHtmlRenderer');
HookMachine::add([Container::class, 'construct'], '\ix\Container\ContainerHooksSession::hookContainerSession');
HookMachine::add([Container::class, 'construct'], '\ix\Container\ContainerHooksEasyCSRF::hookContainerEasyCSRFSession');
/* CSRF error */
HookMachine::add([Controller::class, 'request', 'invalidCSRFToken'], '\ix\Controller\ControllerHookInvalidCSRFTokenErrorPage::hookControllerInvalidCSRFToken');
/* Application alerters */
HookMachine::add([\NeotelApply\IndexController::class, 'sendAlert'], '\NeotelApply\ApplicationAlerters::pushover');
HookMachine::add([\NeotelApply\IndexController::class, 'sendAlert'], '\NeotelApply\ApplicationAlerters::discord');
/* Application routes */
HookMachine::add([Application::class, 'create_app', 'routeRegister'], (function ($key, $app) {
$app->redirect('/', '/register', 301);
$app->any('/register', \NeotelApply\IndexController::class);
return $app;
}));

34
composer.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "u1f408/neotel-application",
"description": "tel.tilde.org.nz application form",
"type": "project",
"private": true,
"authors": [
{
"name": "Iris System",
"email": "iris@iris.ac.nz"
}
],
"repositories": [
{
"type": "vcs",
"url": "https://github.com/u1f408/ix-framework"
}
],
"require": {
"ext-curl": "*",
"ext-json": "*",
"u1f408/ix-framework": "dev-main",
"vlucas/phpdotenv": "^5.3",
"serhiy/pushover": "^1.2"
},
"require-dev": {
"phpstan/phpstan": "^0.12.87",
"phpunit/phpunit": "^9.5"
},
"autoload": {
"psr-4": {
"NeotelApply\\": "lib/"
}
}
}

3921
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace NeotelApply;
use IrisHelpers\CurlHelpers;
use Serhiy\Pushover;
use Serhiy\Pushover\Api\Message\Message as PushoverMessage;
use Serhiy\Pushover\Api\Message\Notification as PushoverNotification;
final class ApplicationAlerters {
/**
* @param string[] $key Hook key (unused)
* @param mixed[] $params Array of [user, tilde, message]
* @return mixed[] Array of [user, tilde, message]
*/
public static function pushover(array $key, array $params): array {
list($user, $tilde, $message) = $params;
$application = new Pushover\Application($_ENV[IX_ENVBASE . '_PUSHOVER_API_TOKEN']);
$recipient = new Pushover\Recipient($_ENV[IX_ENVBASE . '_PUSHOVER_USER_KEY']);
$pushovermessage = new PushoverMessage(
"from {$user}@{$tilde}\n> {$message}",
'new neotel application',
);
$notification = new PushoverNotification($application, $recipient, $pushovermessage);
$response = $notification->push();
return [$user, $tilde, $message];
}
/**
* @param string[] $key Hook key (unused)
* @param mixed[] $params Array of [user, tilde, message]
* @return mixed[] Array of [user, tilde, message]
*/
public static function discord(array $key, array $params): array {
list($user, $tilde, $message) = $params;
$data = json_encode([
"content" => "From `{$user}@{$tilde}` \n> {$message}",
]);
$curl_opts = [
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $data,
];
$response = CurlHelpers::fetchUrl($_ENV[IX_ENVBASE . "_DISCORD_WEBHOOK_URL"], $curl_opts, true);
return [$user, $tilde, $message];
}
}

54
lib/Configuration.php Normal file
View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace NeotelApply;
use Dotenv\Dotenv;
class Configuration {
/** @var ?Dotenv $dotenv */
private static $dotenv = null;
/**
* Create or return a Dotenv instance
*
* @param string $mode
* @return Dotenv
*/
public static function load(string $mode = 'base'): Dotenv {
if (self::$dotenv === null) {
self::$dotenv = self::create($mode);
}
return self::$dotenv;
}
/**
* Construct a new Dotenv instance
*
* @param string $mode
* @return Dotenv
*/
private static function create(string $mode = 'base'): Dotenv {
// Construct and load Dotenv
$dotenv = Dotenv::createImmutable(IX_BASE);
$dotenv->load();
// Database shit
$dotenv->required('REDIS_URL')->notEmpty();
// Require an environment
$dotenv->required('APP_ENV')->allowedValues(['development', 'test', 'production']);
// Require a session cookie
$dotenv->required(IX_ENVBASE . '_SESSIONCOOKIE')->notEmpty();
// Pushover application and user keys
$dotenv->required(IX_ENVBASE . '_PUSHOVER_API_TOKEN')->notEmpty();
$dotenv->required(IX_ENVBASE . '_PUSHOVER_USER_KEY')->notEmpty();
// Discord webhook URL
$dotenv->required(IX_ENVBASE . '_DISCORD_WEBHOOK_URL')->notEmpty();
return $dotenv;
}
}

136
lib/IndexController.php Normal file
View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace NeotelApply;
use ix\HookMachine;
use ix\Controller\Controller;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Slim\Exception\HttpInternalServerErrorException;
class IndexController extends Controller {
/**
* @param Request $request The Request object
* @param Response $response The Response object
* @param mixed[] $args Arguments passed from the router (if any)
* @return Response The resulting Response object
*/
public function requestGET(Request $request, Response $response, ?array $args = []): Response {
$session = $this->container->get('session')->ensure_create()->retrieve();
if (!array_key_exists('numbers', $session->session_data)) {
$session->session_data['numbers'] = implode(',', [
random_int(1, 9),
random_int(1, 9),
]);
}
$numbers = array_map((function($i) { return intval($i); }), explode(',', $session->session_data['numbers']));
$csrf_token = $this->container->get('csrf')->generate('csrf');
$html = $this->container->get('html');
$messageBanner = null;
if (array_key_exists('message', $args)) {
$messageBanner = $html->tagHasChildren('div', ['class' => 'message'], $args['message']);
}
$response->getBody()->write($html->renderDocument(
[
$html->tag('meta', ['charset' => 'utf-8']),
$html->tag('meta', ['name' => 'viewport', 'content' => 'initial-scale=1, width=device-width']),
$html->tag('link', ['rel' => 'stylesheet', 'href' => 'styles.css']),
$html->tagHasChildren('title', [], 'register for tel.tilde.org.nz'),
],
[
$html->tagHasChildren('main', [], ...[
$html->tagHasChildren('h1', [], ...[
'register for ',
$html->tagHasChildren('a', ['href' => 'https://tel.tilde.org.nz'], 'tel.tilde.org.nz'),
]),
$messageBanner ?? '',
$html->tagHasChildren('p', [], ...[
"If you previously had a tilde.tel extension, please mention that in your application!",
]),
$html->tagHasChildren('form', ['class' => 'form', 'method' => 'POST'], ...[
$html->tag('input', ['type' => 'hidden', 'name' => '_csrf', 'value' => strval($csrf_token)]),
$html->tagHasChildren('label', ['for' => 'username'], 'Username (required)'),
$html->tag('input', ['id' => 'username', 'name' => 'username', 'type' => 'text', 'required' => 'required']),
$html->tagHasChildren('label', ['for' => 'tilde'], 'Your tilde/pubnix (required)'),
$html->tag('input', ['id' => 'tilde', 'name' => 'tilde', 'type' => 'text', 'required' => 'required']),
$html->tagHasChildren('label', ['for' => 'message'], 'Anything you want to mention?'),
$html->tagHasChildren('textarea', ['id' => 'message', 'name' => 'message'], ''),
$html->tagHasChildren('label', ['for' => 'verify'], "What is {$numbers[0]} plus {$numbers[1]}?"),
$html->tag('input', ['id' => 'verify', 'name' => 'verify', 'type' => 'number', 'required' => 'required']),
$html->tagHasChildren('button', ['type' => 'submit'], 'Submit request'),
]),
]),
],
));
return $response;
}
/**
* @param Request $request The Request object
* @param Response $response The Response object
* @param mixed[] $args Arguments passed from the router (if any)
* @return Response The resulting Response object
*/
public function requestPOST(Request $request, Response $response, ?array $args = []): Response {
$session = $this->container->get('session')->ensure_create()->retrieve();
if (!array_key_exists('numbers', $session->session_data)) {
return $response->withHeader('Location', '/register');
}
$numbers = array_map((function($i) { return intval($i); }), explode(',', $session->session_data['numbers']));
$query_values = (array) $request->getParsedBody();
// Check CSRF
$csrf_token = null;
if (array_key_exists('_csrf', $query_values)) $csrf_token = trim($query_values['_csrf']);
$this->container->get('csrf')->check('csrf', $csrf_token);
// Check the math question
$given_sum = null;
if (array_key_exists('verify', $query_values)) $given_sum = intval(trim($query_values['verify']));
if ($given_sum != $numbers[0] + $numbers[1]) {
return $this->requestGET($request, $response, array_merge($args, [
'message' => 'The provided verification was incorrect.',
]));
}
// We're good, send the notifications
HookMachine::execute([self::class, 'sendAlert'], [
$query_values['username'],
$query_values['tilde'],
$query_values['message'] ?? '[no message provided]',
]);
// And return a success message
$html = $this->container->get('html');
$response->getBody()->write($html->renderDocument(
[
$html->tag('meta', ['charset' => 'utf-8']),
$html->tag('meta', ['name' => 'viewport', 'content' => 'initial-scale=1, width=device-width']),
$html->tag('link', ['rel' => 'stylesheet', 'href' => 'styles.css']),
$html->tagHasChildren('title', [], 'register for tel.tilde.org.nz'),
],
[
$html->tagHasChildren('main', [], ...[
$html->tagHasChildren('h1', [], ...[
'register for ',
$html->tagHasChildren('a', ['href' => 'https://tel.tilde.org.nz'], 'tel.tilde.org.nz'),
]),
$html->tagHasChildren('div', ['class' => 'message'], ...[
"Your registration request has been submitted!",
]),
]),
],
));
return $response;
}
}

12
phpstan.neon Normal file
View File

@ -0,0 +1,12 @@
parameters:
level: 7
bootstrapFiles:
- tests/bootstrap.php
paths:
- lib
- public
ignoreErrors:
# Let the i18n stuff fly
- '#Function L not found#'

15
phpunit.xml Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="true" backupStaticAttributes="true" bootstrap="tests/bootstrap.php">
<testsuites>
<testsuite name="Main test suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./lib</directory>
</include>
</coverage>
</phpunit>

4
public/.htaccess Normal file
View File

@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

15
public/index.php Normal file
View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
require_once(dirname(dirname(($p = realpath(__FILE__)) === false ? __FILE__ : $p)) . '/bootstrap.php');
/* Hack to allow PHP development server to serve static files */
if (php_sapi_name() === 'cli-server') {
$fileName = __DIR__ . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
if (file_exists($fileName) && !is_dir($fileName)) return false;
}
/* Load configuration */
\NeotelApply\Configuration::load();
/* Run application */
(new \ix\Application\Application())->create_app()->run();

75
public/styles.css Normal file
View File

@ -0,0 +1,75 @@
*,
*:before,
*:after
{
box-sizing: border-box;
}
html,
body
{
margin: 0;
padding: 0;
width: 100%;
max-width: 100vw;
font-family: sans-serif;
}
main
{
display: flex;
flex-flow: column wrap;
justify-content: center;
align-items: center;
padding: 1rem;
width: 100%;
max-width: 100vw;
}
main > *
{
max-width: calc(100vw - 2rem);
}
.message
{
width: 100%;
margin: 1rem 0;
padding: 0.5rem 1rem;
color: #000;
background: #eee;
border: 1px solid #aaa;
border-radius: 2px;
}
.message.message-error
{
background: #c22;
color: #fff;
border-color: #800;
}
.form
{
width: 100%;
margin: 0 0 1rem 0;
}
.form input:not([type = "checkbox"]),
.form select,
.form button,
.form textarea
{
display: block;
width: 100%;
padding: 0.25rem;
margin-bottom: 0.5rem;
border: 1px solid #000;
border-radius: 2px;
}

5
tests/bootstrap.php Normal file
View File

@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
define('IX_ENVBASE', 'SITE');
define('IX_BASE', dirname(__DIR__));
require_once(IX_BASE . '/vendor/autoload.php');