Initial commit

This commit is contained in:
Jill Stingray 2020-05-29 21:49:16 +03:00
commit bda6f5faf2
52 changed files with 5631 additions and 0 deletions

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
* text=auto
*.* text eol=lf
*.dat binary

21
.gitignore vendored Normal file
View File

@ -0,0 +1,21 @@
/vendor
/chandler.yml
/extensions/available/*
/extensions/enabled/*
!/extensions/available/.gitkeep
!/extensions/enabled/.gitkeep
/tmp/cache/di_*
/tmp/plugin_artifacts/*
/tmp/cache/database/*
/tmp/cache/templates/*
/tmp/cache/yaml/*
!/tmp/plugin_artifacts/.gitkeep
!/tmp/cache/database/.gitkeep
!/tmp/cache/templates/.gitkeep
!/tmp/cache/yaml/.gitkeep
/htdocs/*
!/htdocs/.htaccess
!/htdocs/index.php

BIN
3rdparty/maxmind/GeoIP.dat vendored Normal file

Binary file not shown.

13
COPYING Normal file
View File

@ -0,0 +1,13 @@
Copyright (c) 2020, Veselcraft Originals
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

23
INSTALL.md Normal file
View File

@ -0,0 +1,23 @@
You will need the following software for the installation:
* Apache Web Server (2.4 or later)
* Composer (for setup)
* `ln` program (for setup, comes with GNU Coreutils)
* PHP (7.3 or later)
* YAML extension
* Percona Server or MySQL 8+ (with legacy auth mechanism)
Please note that libchandler uses Sodium PHP extension.
This extension is included in default php7.3+ setup, but some hosting providers disable it.
Please, contact your hosting provider and ask them whether sodium is available.
Also, some plugins may require some additional dependencies from Packagist/NPM, so, you
may need to have Yarn installed to correctly setup dependencies.
Installation steps:
* Download this repo as archive and extract it
* Run `composer install`
* Download plugin, that provides Web App and extract it to `extensions/available`
* Symlink plugin folder from `extensions/available` to `extensions/enabled`
* Edit example config and remove `-example` from its name
* * Set root app to your Web App plugin
* * Generate your secret key (Random string, which length is exactly 128 characters)
* Create new VHost and point it's documentroot to `htdocs` folder

9
README.md Normal file
View File

@ -0,0 +1,9 @@
# Chandler
Chandler is PHP-based web-framework/web-portal. By itself it's pretty useless, but you can install plugins/apps.
# Plugins
Plugins may provide a Web Application or be a library or hook.
Web Apps are mounted to a path like this: `/<id>` (where `id = app id`), but one app can be mounted to root.
# State of this repo
This product is still in development phase, we are currently writing documentation/tests and API is going to change.

18
chandler-example.yml Normal file
View File

@ -0,0 +1,18 @@
chandler:
debug: true
websiteUrl: null
rootApp: "root"
preferences:
appendExtension: "xhtml"
adminUrl: "/chandlerd"
exposeChandler: true
database:
dsn: "mysql:host=localhost;dbname=db"
user: "root"
password: "root"
security:
secret: ""
sessionDuration: 14

140
chandler/Bootstrap.php Normal file
View File

@ -0,0 +1,140 @@
<?php declare(strict_types=1);
require_once(dirname(__FILE__) . "/../vendor/autoload.php");
use Tracy\Debugger;
define("CHANDLER_VER", "0.0.1", false);
define("CHANDLER_ROOT", dirname(__FILE__) . "/..", false);
define("CHANDLER_ROOT_CONF", yaml_parse_file(CHANDLER_ROOT . "/chandler.yml")["chandler"]);
/**
* Bootstrap class, that is called during framework starting phase.
* Initializes everything.
*
* @author kurotsun <celestine@vriska.ru>
* @internal
*/
class Bootstrap
{
/**
* Starts Tracy debugger session and installs panels.
*
* @internal
* @return void
*/
private function registerDebugger(): void
{
Debugger::enable(CHANDLER_ROOT_CONF["debug"] ? Debugger::DEVELOPMENT : Debugger::PRODUCTION);
Debugger::getBar()->addPanel(new Chandler\Debug\DatabasePanel);
}
/**
* Loads procedural APIs.
*
* @internal
* @return void
*/
private function registerFunctions(): void
{
foreach(glob(CHANDLER_ROOT . "/chandler/procedural/*.php") as $procDef)
require $procDef;
}
/**
* Set ups autoloaders.
*
* @internal
* @return void
*/
private function registerAutoloaders(): void
{
spl_autoload_register(function($class): void
{
if(strpos($class, "Chandler\\") !== 0) return;
require_once(str_replace("\\", "/", str_replace("Chandler\\", CHANDLER_ROOT . "/chandler/", $class)) . ".php");
}, true, true);
}
/**
* Defines constant CONNECTING_IP, that stores end user's IP address.
* Uses X-Forwarded-For if present.
*
* @internal
* @return void
*/
private function defineIP(): void
{
if(isset($_SERVER["HTTP_X_FORWARDED_FOR"])) {
$path = explode(", ", $_SERVER["HTTP_X_FORWARDED_FOR"]);
$ip = $path[0];
} else {
$ip = $_SERVER["REMOTE_ADDR"];
}
define("CONNECTING_IP", $ip, false);
}
/**
* Initializes GeoIP, sets DB directory.
*
* @internal
* @return void
*/
private function setupGeoIP(): void
{
geoip_setup_custom_directory(CHANDLER_ROOT . "/3rdparty/maxmind/");
}
/**
* Bootstraps extensions.
*
* @internal
* @return void
*/
private function igniteExtensions(): void
{
Chandler\Extensions\ExtensionManager::i();
}
/**
* Starts router and serves request.
*
* @internal
* @param string $url Request URL
* @return void
*/
private function route(string $url): void
{
ob_start();
$router = Chandler\MVC\Routing\Router::i();
if(($output = $router->execute($url, NULL)) !== null)
echo $output;
else
chandler_http_panic(404, "Not Found", "No routes for $url.");
ob_flush();
ob_end_flush();
flush();
}
/**
* Starts framework.
*
* @internal
* @return void
*/
function ignite(): void
{
header("Referrer-Policy: strict-origin-when-cross-origin");
$this->registerFunctions();
$this->registerAutoloaders();
$this->defineIP();
$this->registerDebugger();
$this->igniteExtensions();
$this->route(function_exists("get_current_url") ? get_current_url() : $_SERVER["REQUEST_URI"]);
}
}
return new Bootstrap;

View File

@ -0,0 +1,13 @@
<?php declare(strict_types=1);
return (function(): void
{
$result = (require(__DIR__ . "/verify_user.php"))();
if(is_null($result)) {
header("HTTP/1.1 307 Internal Redirect");
header("Location: /chandlerd/login");
exit;
} else if(!$result) {
chandler_http_panic(403, "Bruh moment", "You are not allowed to look at the admin panel");
}
});

View File

@ -0,0 +1,3 @@
<?php declare(strict_types=1);
(require(__DIR__ . "/assert_user.php"))();

View File

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
use Chandler\Security\Authenticator;
return (function(): ?bool
{
$auth = Authenticator::i();
$user = $auth->getUser();
if(!$user) return NULL;
return $user->can("access")->model("admin")->whichBelongsTo(NULL);
});

View File

@ -0,0 +1,50 @@
<?php
use Chandler\Database\DatabaseConnection;
if(!isset($_GET["GUID"])) exit(header("HTTP/1.1 400 Bad Request"));
$db = DatabaseConnection::i()->getContext();
$user = $db->table("ChandlerUsers")->where("id", $_GET["GUID"])->fetch();
if(!$user) exit(header("HTTP/1.1 404 Not Found"));
$info = [
"id" => $user->id,
"login" => $user->login,
"isDeleted" => (bool) $user->deleted,
];
if($_GET["includeActionList"] ?? false) {
$info["permissions"] = [];
foreach((new Chandler\Security\User($user))->getPermissions()->getPermissions() as $perm) {
$info["permissions"][] = [
"state" => [
"humanReadable" => "Explicitly " . ucfirst($perm->status ? "allowed" : "disallowed"),
"state" => (bool) $perm->status,
],
"conditions" => [
["humanReadable" => is_null($perm->context)
? "Rule applied in all situations"
: ($perm->context === 0
? "Models, that are owned by $user->login"
: "Only model with ID = $perm->context"),
"context" => $perm->context,],
],
"action" => $perm->action,
"model" => $perm->model,
];
}
}
if($_GET["includeSessions"] ?? false) {
$info["sessions"] = [];
foreach($user->related("ChandlerTokens.user") as $token) {
$info["sessions"][] = [
"token" => $token->token,
"ip" => $token->ip,
"userAgent" => $token->ua,
];
}
}
header("Content-Type: application/json");
echo json_encode($info);

View File

@ -0,0 +1,102 @@
<?php declare(strict_types=1);
namespace Chandler\Database;
use Chandler\Database\DatabaseConnection;
use Nette\Database\Table\Selection;
use Nette\Database\Table\ActiveRow;
use Nette\InvalidStateException as ISE;
abstract class DBEntity
{
protected $record;
protected $changes;
protected $deleted;
protected $tableName;
function __construct(?ActiveRow $row = NULL)
{
if(is_null($row)) return;
$_table = $row->getTable()->getName();
if($_table !== $this->tableName)
throw new ISE("Invalid data supplied for model: table $_table is not compatible with table" . $this->tableName);
$this->record = $row;
}
function __call(string $fName, array $args)
{
if(substr($fName, 0, 3) === "set") {
$field = mb_strtolower(substr($fName, 3));
$this->stateChanges($field, $args[0]);
} else {
throw new \Error("Call to undefined method " . get_class($this) . "::$fName");
}
}
private function getTable(): Selection
{
return DatabaseConnection::i()->getContext()->table($this->tableName);
}
protected function getRecord(): ?ActiveRow
{
return $this->record;
}
protected function stateChanges(string $column, $value): void
{
if(!is_null($this->record))
$t = $this->record->{$column}; #Test if column exists
$this->changes[$column] = $value;
}
function getId(): int
{
return $this->getRecord()->id;
}
function isDeleted(): bool
{
return (bool) $this->getRecord()->deleted;
}
function delete(bool $softly = true): void
{
if(is_null($this->record))
throw new ISE("Can't delete a model, that hasn't been flushed to DB. Have you forgotten to call save() first?");
if($softly) {
$this->record = $this->getTable()->where("id", $this->record->id)->update(["deleted" => true]);
} else {
$this->record->delete();
$this->deleted = true;
}
}
function undelete(): void
{
if(is_null($this->record))
throw new ISE("Can't undelete a model, that hasn't been flushed to DB. Have you forgotten to call save() first?");
$this->getTable()->where("id", $this->record->id)->update(["deleted" => false]);
}
function save(): void
{
if(is_null($this->record)) {
$this->record = $this->getTable()->insert($this->changes);
} else if($this->deleted) {
$this->record = $this->getTable()->insert((array) $this-->record);
} else {
$this->record->getTable()->where("id", $this->record->id)->update($this->changes);
$this->record = $this->getTable()->get($this->record->id);
}
$this->changes = [];
}
use \Nette\SmartObject;
}

View File

@ -0,0 +1,82 @@
<?php declare(strict_types=1);
namespace Chandler\Database;
use Nette\Database;
use Nette\Caching\Storages\FileStorage;
use Nette\Database\Conventions\DiscoveredConventions;
class DatabaseConnection
{
private static $self = NULL;
private $connection;
private $context;
private function __construct(string $dsn, string $user, string $password, ?string $tmpFolder = NULL)
{
$storage = new FileStorage($tmpFolder ?? (CHANDLER_ROOT . "/tmp/cache/database"));
$connection = new Database\Connection($dsn, $user, $password);
$structure = new Database\Structure($connection, $storage);
$conventions = new DiscoveredConventions($structure);
$context = new Database\Context($connection, $structure, $conventions, $storage);
$this->connection = $connection;
$this->context = $context;
if(CHANDLER_ROOT_CONF["debug"])
$this->connection->onQuery = $this->getQueryCallback();
}
private function __clone() {}
private function __wakeup() {}
protected function getQueryCallback(): array
{
return [(function($connection, $result) {
if($result instanceof \Nette\Database\DriverException)
return;
if(!isset($GLOBALS["dbgSqlQueries"])) {
$GLOBALS["dbgSqlQueries"] = [];
$GLOBALS["dbgSqlTime"] = 0;
}
$params = $result->getParameters();
$GLOBALS["dbgSqlQueries"][] = str_replace(str_split(str_repeat("?", sizeof($params))), $params, $result->getQueryString());
$GLOBALS["dbgSqlTime"] += $result->getTime();
})];
}
function getConnection(): Database\Connection
{
return $this->connection;
}
function getContext(): Database\Context
{
return $this->context;
}
static function i(): DatabaseConnection
{
return static::$self ?? static::$self = new static(
CHANDLER_ROOT_CONF["database"]["dsn"],
CHANDLER_ROOT_CONF["database"]["user"],
CHANDLER_ROOT_CONF["database"]["password"]
);
}
static function connect(array $options): DatabaseConnection
{
$id = sha1(serialize($options)) . "__DATABASECONNECTION\$feafccc";
if(!isset($GLOBALS[$id])) {
$GLOBALS[$id] = new static(
$options["dsn"],
$options["user"],
$options["password"],
isset($options["caching"]) ? ($options["caching"]["folder"] ?? NULL) : NULL
);
}
return $GLOBALS[$id];
}
}

View File

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
namespace Chandler\Debug;
use Nette\Database\Helpers as DbHelpers;
class DatabasePanel implements \Tracy\IBarPanel
{
use \Nette\SmartObject;
public function getTab()
{
$count = sizeof($GLOBALS["dbgSqlQueries"]);
$time = ceil($GLOBALS["dbgSqlTime"] * 1000);
$svg = file_get_contents(__DIR__ . "/templates/db-icon.svg");
return <<<EOF
<span title="DB Queries">
$svg
<span class="tracy-label">$count queries ($time ms)</span>
</span>
EOF;
}
public function getPanel()
{
$html = <<<HTML
<h1>Queries:</h1>
<div class="tracy-inner">
<div class="tracy-inner-container">
<table class="tracy-sortable">
HTML;
foreach($GLOBALS["dbgSqlQueries"] as $query) {
$query = DbHelpers::dumpSql($query);
$html .= "<tr><td>$query</td></tr>";
}
$html .= "</table></div></div>";
return $html;
}
}

View File

@ -0,0 +1 @@
<svg viewBox="0 0 2048 2048"><path fill="#b079d6" d="M1024 896q237 0 443-43t325-127v170q0 69-103 128t-280 93.5-385 34.5-385-34.5-280-93.5-103-128v-170q119 84 325 127t443 43zm0 768q237 0 443-43t325-127v170q0 69-103 128t-280 93.5-385 34.5-385-34.5-280-93.5-103-128v-170q119 84 325 127t443 43zm0-384q237 0 443-43t325-127v170q0 69-103 128t-280 93.5-385 34.5-385-34.5-280-93.5-103-128v-170q119 84 325 127t443 43zm0-1152q208 0 385 34.5t280 93.5 103 128v128q0 69-103 128t-280 93.5-385 34.5-385-34.5-280-93.5-103-128v-128q0-69 103-128t280-93.5 385-34.5z"/>

After

Width:  |  Height:  |  Size: 548 B

24
chandler/Email/Email.php Normal file
View File

@ -0,0 +1,24 @@
<?php declare(strict_types=1);
namespace Chandler\Email;
use Swift_SmtpTransport;
use Swift_Message;
use Swift_Mailer;
class Email
{
static function send(string $to, string $subject, string $html)
{
$transport = new Swift_SmtpTransport(CHANDLER_ROOT_CONF["email"]["host"], CHANDLER_ROOT_CONF["email"]["port"], "ssl");
$transport->setUsername(CHANDLER_ROOT_CONF["email"]["addr"]);
$transport->setPassword(CHANDLER_ROOT_CONF["email"]["pass"]);
$message = new Swift_Message($subject);
$message->getHeaders()->addTextHeader("Sensitivity", "Company-Confidential");
$message->setFrom(CHANDLER_ROOT_CONF["email"]["addr"]);
$message->setTo($to);
$message->setBody($html, "text/html");
$mailer = new Swift_Mailer($transport);
return $mailer->send($message);
}
}

View File

@ -0,0 +1,33 @@
<?php declare(strict_types=1);
namespace Chandler\Eventing;
use Chandler\Patterns\TSimpleSingleton;
class EventDispatcher
{
private $hooks = [];
function addListener($hook): bool
{
$this->hooks[] = $hook;
return true;
}
function pushEvent(Events\Event $event): Events\Event
{
foreach($hooks as $hook) {
if($event instanceof Events\Cancelable)
if($event->isCancelled())
break;
$method = "on" . str_replace("Event", "", get_class($event));
if(!method_exists($hook, $methodName)) continue;
$hook->$method($event);
}
return $event;
}
use TSimpleSingleton;
}

View File

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace Chandler\Eventing\Events;
interface Cancelable
{
protected $cancelled;
function cancel(): void;
function isCancelled(): bool;
}

View File

@ -0,0 +1,37 @@
<?php declare(strict_types=1);
namespace Chandler\Eventing\Events;
class Event
{
protected $data;
protected $code;
protected $time;
protected $pristine = true;
function __construct($data = "", float $code = 0)
{
$this->data = $data;
$this->code = $code;
$this->time = time();
}
function getData()
{
return $this->data;
}
function getCode()
{
return $this->code;
}
function getTime()
{
return $this->time;
}
function isTainted()
{
return !$this->pristine;
}
}

View File

@ -0,0 +1,120 @@
<?php declare(strict_types=1);
namespace Chandler\Extensions;
use Chandler\Eventing\EventDispatcher;
use Chandler\Patterns\TSimpleSingleton;
use Chandler\MVC\Routing\Router;
use Nette\Utils\Finder;
define("CHANDLER_EXTENSIONS", CHANDLER_ROOT . "/extensions", false);
define("CHANDLER_EXTENSIONS_AVAILABLE", CHANDLER_EXTENSIONS . "/available", false);
define("CHANDLER_EXTENSIONS_ENABLED", CHANDLER_EXTENSIONS . "/enabled", false);
class ExtensionManager
{
private $extensions = [];
private $router = NULL;
private $rootApp = NULL;
private $eventLoop = NULL;
private function __construct()
{
foreach(Finder::findDirectories("*")->in(CHANDLER_EXTENSIONS_AVAILABLE) as $directory) {
$extensionName = $directory->getFilename();
$directory = $directory->getRealPath();
$config = "$directory/manifest.yml";
if(!file_exists($config)) {
trigger_error("Skipping $extensionName for not having a valid configuration file ($config is not found)", E_USER_WARNING);
continue;
}
$this->extensions[$extensionName] = (object) chandler_parse_yaml($config);
$this->extensions[$extensionName]->id = $extensionName;
$this->extensions[$extensionName]->rawName = $directory;
$this->extensions[$extensionName]->enabled = false;
}
foreach(Finder::find("*")->in(CHANDLER_EXTENSIONS_ENABLED) as $directory) { #findDirectories doesn't work with symlinks
if(!is_dir($directory->getRealPath())) continue;
$extension = $directory->getFilename();
if(!array_key_exists($extension, $this->extensions)) {
trigger_error("Extension $extension is enabled, but not available, skipping", E_USER_WARNING);
continue;
}
$this->extensions[$extension]->enabled = true;
}
if(!array_key_exists(CHANDLER_ROOT_CONF["rootApp"], $this->extensions) || !$this->extensions[CHANDLER_ROOT_CONF["rootApp"]]->enabled) {
trigger_error("Selected root app is not available", E_USER_ERROR);
}
$this->rootApp = CHANDLER_ROOT_CONF["rootApp"];
$this->eventLoop = EventDispatcher::i();
$this->router = Router::i();
$this->init();
}
private function init(): void
{
foreach($this->getExtensions(true) as $name => $configuration) {
spl_autoload_register(@create_function("\$class", "
if(!substr(\$class, 0, " . iconv_strlen("$name\\") . ") === \"$name\\\\\") return false;
require_once CHANDLER_ROOT . \"/extensions/enabled/\" . str_replace(\"\\\\\", \"/\", \$class) . \".php\";
"));
define(mb_strtoupper($name) . "_ROOT", CHANDLER_ROOT . "/extensions/enabled/$name", false);
define(mb_strtoupper($name) . "_ROOT_CONF", chandler_parse_yaml(CHANDLER_ROOT . "/extensions/enabled/$name/$name.yml"), false);
if(isset($configuration->init))
(require(CHANDLER_ROOT . "/extensions/enabled/$name/" . $configuration->init))();
if(is_dir($hooks = CHANDLER_EXTENSIONS_ENABLED . "/$name/Hooks")) {
foreach(Finder::findFiles("*Hook.php")->in($hooks) as $hookFile) {
$hookClassName = "$name\\Hooks\\" . str_replace(".php", "", end(explode("/", $hookFile)));
$hook = new $hookClassName;
$this->eventLoop->addListener($hook);
}
}
if(is_dir($app = CHANDLER_EXTENSIONS_ENABLED . "/$name/Web")) #"app" means "web app", thus variable is called $app
$this->router->readRoutes("$app/routes.yml", $name, $this->rootApp !== $name);
}
}
function getExtensions(bool $onlyEnabled = false): array
{
return $onlyEnabled
? array_filter($this->extensions, function($e) { return $e->enabled; })
: $this->extensions;
}
function getExtension(string $name): ?object
{
return @$this->extensions[$name];
}
function disableExtension(string $name): void
{
if(!array_key_exists($name, $this->getExtensions(true))) return;
if(!unlink(CHANDLER_EXTENSIONS_ENABLED . "/$name")) throw new \Exception("Could not disable extension");
}
function enableExtension(string $name): void
{
if(array_key_exists($name, $this->getExtensions(true))) return;
$path = CHANDLER_EXTENSIONS_AVAILABLE . "/$name";
if(!is_dir($path)) throw new \Exception("Extension doesn't exist");
if(!symlink($path, str_replace("available", "enabled", $path))) throw new \Exception("Could not enable extension");
}
use TSimpleSingleton;
}

View File

@ -0,0 +1,5 @@
<?php declare(strict_types=1);
namespace Chandler\MVC\Exceptions;
final class InterruptedException extends \Exception
{}

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace Chandler\MVC;
use Latte\Engine as TemplatingEngine;
interface IPresenter
{
function getTemplatingEngine(): TemplatingEngine;
function getTemplateScope(): array;
function onStartup(): void;
function onBeforeRender(): void;
function onAfterRender(): void;
function onStop(): void;
function onDestruction(): void;
}

View File

@ -0,0 +1,5 @@
<?php declare(strict_types=1);
namespace Chandler\MVC\Routing\Exceptions;
final class UnknownTypeAliasException extends \Exception
{}

View File

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace Chandler\MVC\Routing;
class Route
{
public $raw;
public $regex;
public $namespace;
public $presenter;
public $action;
}

View File

@ -0,0 +1,272 @@
<?php declare(strict_types=1);
namespace Chandler\MVC\Routing;
use Chandler\Patterns\TSimpleSingleton;
use Chandler\Eventing\EventDispatcher;
use Chandler\Session\Session;
use Chandler\MVC\Exceptions\InterruptedException;
use Chandler\MVC\IPresenter;
use Nette\DI;
class Router
{
const HANDLER_DELIMITER = "%([#@❤]|\->)%";
const ALIAS_REGEX = "%{(\??\!?([A-z]++))}%";
private $url = NULL;
private $routes = [];
private $statics = [];
private $scope = [];
private $events;
protected $types = [
"num" => "(-?\d++)",
"text" => "([A-z0-9]++)",
"slug" => "([A-z0-9А\-_ ]++)",
];
private function __construct()
{
$this->events = EventDispatcher::i();
}
private function computeRegExp(string $route, array $customAliases = [], ?string $prefix = NULL): string
{
$regexp = preg_replace_callback(Router::ALIAS_REGEX, function($matches) use ($customAliases) {
if($matches[1][0] === "?") {
$replacement = !isset($customAliases[$matches[2]])
? NULL
: ($matches[1][1] !== "!" ? "(" : "(?:") . $customAliases[$matches[2]] . ")";
} else {
$replacement = $this->types[$matches[1]];
}
if(!$replacement) {
$exMessage = "Unknown type alias: $matches[1].";
$exMessage .= " (Available options are: " . implode(", ", array_keys($this->types));
if(sizeof($customAliases) > 0)
$exMessage .= " or any of these user-defined aliases: " . implode(", ", array_keys($customAliases)) . ")";
else
$exMessage .= ")";
throw new Exceptions\UnknownTypeAliasException($exMessage);
}
return $replacement;
}, addslashes($route));
if(!is_null($prefix)) {
$regexp = "\\/$prefix\\" . ($route === "/" ? "/" : "/$regexp");
}
return "%^$regexp$%";
}
function makeCSRFToken(Route $route, string $nonce): string
{
$key = hash("snefru", CHANDLER_ROOT_CONF["security"]["secret"] . bin2hex($nonce));
$data = $route->namespace;
$data .= Session::i()->get("tok", -1);
return hash_hmac("snefru", $data, $key) . "#" . bin2hex($nonce);
}
private function setCSRFStatus(Route $route): void
{
$GLOBALS["csrfCheck"] = false;
$hash = ($_GET["hash"] ?? ($_POST["hash"] ?? false));
if($hash !== false) {
$data = explode("#", $hash);
try {
if(!isset($data[0]) || !isset($data[1])) throw new \SodiumException;
[$hash, $nonce] = $data;
if(sodium_memcmp($this->makeCSRFToken($route, hex2bin($nonce)), "$hash#$nonce") === 0)
$GLOBALS["csrfCheck"] = true;
} catch(\SodiumException $ex) {}
}
$GLOBALS["csrfToken"] = $this->makeCSRFToken($route, openssl_random_pseudo_bytes(4));
}
private function getDI(string $namespace): DI\Container
{
$loader = new DI\ContainerLoader(CHANDLER_ROOT . "/tmp/cache/di_$namespace", true);
$class = $loader->load(function($compiler) use ($namespace) {
$fileLoader = new \Nette\DI\Config\Loader;
$fileLoader->addAdapter("yml", \Nette\DI\Config\Adapters\NeonAdapter::class);
$compiler->loadConfig(CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/di.yml", $fileLoader);
});
return new $class;
}
private function getPresenter(string $namespace, string $presenterName): ?IPresenter
{
$di = $this->getDI($namespace);
$services = $di->findByType("\\$namespace\\Web\\Presenters\\$presenterName" . "Presenter", false);
return $di->getService($services[0], false);
}
private function delegateView(string $filename, IPresenter $presenter): string
{
return $presenter->getTemplatingEngine()->renderToString($filename, $this->scope);
}
private function delegateController(string $namespace, string $presenterName, string $action, array $parameters = []): string
{
$presenter = $this->getPresenter($namespace, $presenterName);
$action = ucfirst($action);
try {
$presenter->onStartup();
$presenter->{"render$action"}(...$parameters);
$presenter->onBeforeRender();
$this->scope += array_merge_recursive($presenter->getTemplateScope(), []); #TODO: add default parameters
#TODO: move this to delegateView
$output = $this->delegateView(
$this->scope["_template"] ?? CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/Presenters/templates/$presenterName/$action.xml",
$presenter
);
$presenter->onAfterRender();
} catch(InterruptedException $ex) {}
$presenter->onStop();
$presenter->onDestruction();
$presenter = NULL;
return $output;
}
private function delegateRoute(Route $route, array $matches): string
{
$parameters = [];
foreach($matches as $param)
$parameters[] = is_numeric($param) ? (int) $param : $param;
$this->setCSRFStatus($route);
return $this->delegateController($route->namespace, $route->presenter, $route->action, $parameters);
}
function delegateStatic(string $namespace, string $path): string
{
$static = $static = $this->statics[$namespace];
if(!isset($static)) return "Fatal error: no route";
if(!file_exists($file = "$static/$path"))
return "Fatal error: no resource";
$hash = "W/\"" . hash_file("snefru", $file) . "\"";
if(isset($_SERVER["HTTP_IF_NONE_MATCH"]))
if($_SERVER["HTTP_IF_NONE_MATCH"] === $hash)
exit(header("HTTP/1.1 304"));
header("Content-Type: " . system_extension_mime_type($file));
header("Content-Size: " . filesize($file));
header("ETag: $hash");
readfile($file);
exit;
}
function reverse(string $hotlink, ...$parameters): ?string
{
if(sizeof($j = explode("!", $hotlink)) === 2)
[$namespace, $hotlink] = $j;
else
$namespace = explode("\\", $this->scope["parentModule"])[0];
[$presenter, $action] = preg_split(Router::HANDLER_DELIMITER, $hotlink);
foreach($this->routes as $route) {
if($route->namespace !== $namespace || $route->presenter !== $presenter) continue;
if(!is_null($action) && $route->action != $action) continue;
$count = preg_match_all(Router::ALIAS_REGEX, $route->raw);
if($count != sizeof($parameters)) continue;
$i = 0;
return preg_replace_callback(Router::ALIAS_REGEX, function() use ($parameters, &$i) {
return $parameters[$i++];
}, $route->raw);
}
return NULL;
}
function push(?string $prefix, string $url, string $namespace, string $presenter, string $action, array $ph): void
{
$route = new Route;
$route->raw = $url;
if(!is_null($prefix))
$route->raw = "/$prefix" . $route->raw;
$route->regex = $this->computeRegExp($url, $ph, $prefix);
$route->namespace = $namespace;
$route->presenter = $presenter;
$route->action = $action;
$this->routes[] = $route;
}
function pushStatic(string $namespace, string $path): void
{
$this->statics[$namespace] = $path;
}
function readRoutes(string $filename, string $namespace, bool $autoprefix = true): void
{
$config = yaml_parse_file($filename);
if(isset($config["static"]))
$this->pushStatic($namespace, CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/$config[static]");
foreach($config["routes"] as $route) {
$route = (object) $route;
$placeholders = $route->placeholders ?? [];
[$presenter, $action] = preg_split(Router::HANDLER_DELIMITER, $route->handler);
$this->push($autoprefix ? $namespace : NULL, $route->url, $namespace, $presenter, $action, $placeholders);
}
}
function getMatchingRoute(string $url): ?array
{
foreach($this->routes as $route)
if(preg_match($route->regex, $url, $matches))
return [$route, array_slice($matches, 1)];
return NULL;
}
function execute(string $url, ?string $parentModule = null): ?string
{
$this->url = parse_url($url, PHP_URL_PATH);
if(!is_null($parentModule)) {
$GLOBALS["parentModule"] = $parentModule;
$this->scope["parentModule"] = $GLOBALS["parentModule"];
}
if(preg_match("%^\/assets\/packages\/static\/([A-z_\\-]++)\/(.++)$%", $this->url, $matches)) {
[$j, $namespace, $file] = $matches;
return $this->delegateStatic($namespace, $file);
}
$match = $this->getMatchingRoute($this->url);
if(!$match)
return NULL;
return $this->delegateRoute(...$match);
}
use TSimpleSingleton;
}

View File

@ -0,0 +1,207 @@
<?php declare(strict_types=1);
namespace Chandler\MVC;
use Chandler\Session\Session;
use Latte\Engine as TemplatingEngine;
use Nette\SmartObject;
abstract class SimplePresenter implements IPresenter
{
const REDIRECT_PERMAMENT = 1;
const REDIRECT_TEMPORARY = 2;
const REDIRECT_PERMAMENT_PRESISTENT = 8;
const REDIRECT_TEMPORARY_PRESISTENT = 7;
protected $mmReader;
protected $template;
protected $errorTemplate = NULL;
function __construct()
{
$this->template = (object) [];
}
function getTemplatingEngine(): TemplatingEngine
{
$latte = new TemplatingEngine;
$macros = new \Latte\Macros\MacroSet($latte->getCompiler());
$latte->setTempDirectory(CHANDLER_ROOT . "/tmp/cache/templates");
$macros->addMacro("css", '
$domain = "' . explode("\\", static::class)[0] . '";
$file = (%node.array)[0];
$realpath = CHANDLER_EXTENSIONS_ENABLED . "/$domain/Web/static/$file";
if(file_exists($realpath)) {
$hash = "sha384-" . base64_encode(hash_file("sha384", $realpath, true));
$mod = bin2hex(filemtime($realpath));
echo "<link rel=\'stylesheet\' href=\'/assets/packages/static/$domain/$file?mod=$mod\' integrity=\'$hash\' />";
} else {
echo "<!-- ERR: $file does not exist. Not including. -->";
}
');
$macros->addMacro("script", '
$domain = "' . explode("\\", static::class)[0] . '";
$file = (%node.array)[0];
$realpath = CHANDLER_EXTENSIONS_ENABLED . "/$domain/Web/static/$file";
if(file_exists($realpath)) {
$hash = "sha384-" . base64_encode(hash_file("sha384", $realpath, true));
$mod = bin2hex(filemtime($realpath));
echo "<script src=\'/assets/packages/static/$domain/$file?mod=$mod\' integrity=\'$hash\'></script>";
} else {
echo "<!-- ERR: $file does not exist. Not including. -->";
}
');
$macros->addMacro("presenter", '
$input = (%node.array);
echo "<!-- Trying to invoke $input[0] through router from ' . static::class . ' -->";
$router = \Chandler\MVC\Routing\Router::i();
$__out = $router->execute($router->reverse(...$input), "' . static::class . '");
echo $__out;
echo "<!-- Inclusion complete -->";
'
);
return $latte;
}
protected function throwError(int $code = 400, string $desc = "Bad Request", string $message = ""): void
{
if(!is_null($this->errorTemplate)) {
header("HTTP/1.0 $code $desc");
$ext = explode("\\", get_class($this))[0];
$path = CHANDLER_EXTENSIONS_ENABLED . "/$ext/Web/Presenters/templates/" . $this->errorTemplate . ".xml";
$latte = new TemplatingEngine;
$latte->setTempDirectory(CHANDLER_ROOT . "/tmp/cache/templates");
$latte->render($path, array_merge_recursive([
"code" => $code,
"desc" => $desc,
"msg" => $message,
], $this->getTemplateScope()));
exit;
} else {
chandler_http_panic($code, $desc, $message);
}
}
protected function assertNoCSRF(): void
{
if(!$GLOBALS["csrfCheck"])
$this->throwError(400, "Bad Request", "CSRF token is missing or invalid.");
}
protected function terminate(): void
{
throw new Exceptions\InterruptedException;
}
protected function notFound(): void
{
$this->throwError(
404,
"Not Found",
"The resource you are looking for has been deleted, had its name changed or doesn't exist."
);
}
protected function getCaller(): string
{
return $GLOBALS["parentModule"] ?? "libchandler:absolute.0";
}
protected function redirect(string $location, int $code = 2): void
{
$code = 300 + $code;
if(($code <=> 300) !== 0 && $code > 399) return;
header("HTTP/1.1 $code");
header("Location: $location");
exit;
}
protected function pass(string $to, ...$args): void
{
$args = array_merge([$to], $args);
$router = \Chandler\MVC\Routing\Router::i();
$__out = $router->execute($router->reverse(...$args), "libchandler:absolute.0");
exit($__out);
}
protected function sendmail(string $to, string $template, array $params): void
{
$emailDir = pathinfo($template, PATHINFO_DIRNAME);
$template .= ".eml.latte";
$renderedHTML = (new TemplatingEngine)->renderToString($template, $params);
$document = new \DOMDocument();
$document->loadHTML($renderedHTML, LIBXML_NOEMPTYTAG);
$querySel = new \DOMXPath($document);
$subject = $querySel->query("//title/text()")->item(0)->data;
foreach($querySel->query("//link[@rel='stylesheet']") as $link) {
$style = $document->createElement("style");
$style->setAttribute("id", uniqid("mail", true));
$style->appendChild(new \DOMText(file_get_contents("$emailDir/assets/css/" . $link->getAttribute("href"))));
$link->parentNode->appendChild($style);
$link->parentNode->removeChild($link);
}
foreach($querySel->query("//img") as $image) {
$imagePath = "$emailDir/assets/res/" . $image->getAttribute("src");
$type = pathinfo($imagePath, PATHINFO_EXTENSION);
$contents = base64_encode(file_get_contents($imagePath));
$image->setAttribute("src", "data:image/$type;base64,$contents");
}
\Chandler\Email\Email::send($to, $subject, $document->saveHTML());
}
protected function queryParam(string $index): ?string
{
return $_GET[$index] ?? NULL;
}
protected function postParam(string $index): ?string
{
$this->assertNoCSRF();
return $_POST[$index] ?? NULL;
}
protected function checkbox(string $name): bool
{
return ($this->postParam($name) ?? "off") === "on";
}
function getTemplateScope(): array
{
return (array) $this->template;
}
function onStartup(): void
{
date_default_timezone_set("UTC");
}
function onBeforeRender(): void
{
$this->template->csrfToken = $GLOBALS["csrfToken"];
}
function onAfterRender(): void
{}
function onStop(): void
{}
function onDestruction(): void
{}
use SmartObject;
}

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace Chandler\Patterns;
use Chandler\Database\DatabaseConnection;
class ActiveRecord
{
private $db; #DB
private $table; #DATA
private $query;
private $row;
private $changes = [];
protected $tableName;
protected $primaryKey = "id";
protected $timestamps = true;
protected $softDelete = true;
function __construct()
{
$this->db = DatabaseConnection::i();
$this->table = $this->db->table($this->tableName);
if(!is_null($row)) $this->row = $row;
$this->resetQuery();
}
private function resetQuery(): void
{
$this->query = clone $this->table;
}
function __call()
}

View File

@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace Chandler\Patterns;
trait TSimpleSingleton
{
private static $self = NULL;
private function __construct() {}
private function __clone() {}
private function __wakeup() {}
static function i()
{
return static::$self ?? static::$self = new static;
}
}

View File

@ -0,0 +1,114 @@
<?php declare(strict_types=1);
namespace Chandler\Security;
use Chandler\Session\Session;
use Chandler\Patterns\TSimpleSingleton;
use Chandler\Database\DatabaseConnection;
class Authenticator
{
private $db;
private $session;
private function __construct()
{
$this->db = DatabaseConnection::i()->getContext();
$this->session = Session::i();
}
private function verifySuRights(string $uId): bool
{
}
private function makeToken(string $user, string $ip, string $ua): string
{
$data = ["user" => $user, "ip" => $ip, "ua" => $ua];
$token = $this->db
->table("ChandlerTokens")
->where($data)
->fetch();
if(!$token) {
$this->db->table("ChandlerTokens")->insert($data);
$token = $this->db->table("ChandlerTokens")->where($data)->fetch();
}
return $token->token;
}
static function verifyHash(string $input, string $hash): bool
{
try {
[$hash, $salt] = explode("$", $hash);
$userHash = bin2hex(
sodium_crypto_pwhash(
16,
$input,
hex2bin($salt),
SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
)
);
if(sodium_memcmp($hash, $userHash) !== 0) return false;
} catch(\SodiumException $ex) {
return false;
}
return true;
}
function getUser(): ?User
{
$token = $this->session->get("tok");
if(!$token) return null;
$token = $this->db
->table("ChandlerTokens")
->where([
"token" => $token,
])
->fetch();
if(!$token) return null;
if($token->ip === CONNECTING_IP && $token->ua === $_SERVER["HTTP_USER_AGENT"]) {
$su = $this->session->get("_su");
$user = $this->db->table("ChandlerUsers")->get($su ?? $token->user);
if(!$user) return null;
return new User($user, !is_null($su));
}
return null;
}
function authenticate(string $user): void
{
$this->session->set("tok", $this->makeToken($user, CONNECTING_IP, $_SERVER["HTTP_USER_AGENT"]));
}
function login(string $id, string $password): bool
{
$user = $this->db->table("ChandlerUsers")->get($id);
if(!$user)
return false;
else if(!$this->verifyHash($password, $user->passwordHash))
return false;
$this->authenticate($id);
return true;
}
function logout(bool $revoke = false): bool
{
$token = $this->session->get("tok");
if(!$token) return false;
if($revoke) $this->db->table("ChandlerTokens")->where("id", $token)->delete();
$this->session->set("tok", NULL);
return true;
}
use TSimpleSingleton;
}

View File

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
namespace Chandler\Security\Authorization;
class Permission
{
const CONTEXT_OWNER = 0;
const CONTEXT_EVERYONE = 1;
const ACTION_READ = "read";
const ACTION_EDIT = "update";
const ACTION_DELETE = "delete";
public $action;
public $model;
public $context;
public $status = true;
}

View File

@ -0,0 +1,43 @@
<?php declare(strict_types=1);
namespace Chandler\Security\Authorization;
class PermissionBuilder
{
private $perm;
private $permissionManager;
function __construct(?Permissions $permMan = NULL)
{
$this->perm = new Permission;
$this->permissionManager = $permMan;
}
function can(string $action): PermissionBuilder
{
$this->perm->action = $action;
return $this;
}
function model(string $model): PermissionBuilder
{
$this->perm->model = $model;
return $this;
}
function whichBelongsTo(?int $to)
{
$this->perm->context = $to;
return is_null($this->permissionManager)
? $this
: $this->permissionManager->hasPermission($this->build());
}
function build(): Permission
{
return $this->perm;
}
}

View File

@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace Chandler\Security\Authorization;
use Chandler\Database\DatabaseConnection;
use Chandler\Security\User;
class Permissions
{
private $db;
private $user;
private $perms = [];
function __construct(User $user)
{
$this->db = DatabaseConnection::i()->getContext();
$this->user = $user;
$this->init();
}
private function init()
{
$uGroups = $this->user->getRaw()->related("ChandlerACLRelations.user")->order("priority ASC")->select("group");
$groups = array_map(function($j) {
return $j->group;
}, iterator_to_array($uGroups));
$permissionsAllowed = $this->db->table("ChandlerACLGroupsPermissions")->where("group IN (?)", $groups);
$permissionsDenied = iterator_to_array((clone $permissionsAllowed)->where("status", false));
$permissionsDenied = array_merge($permissionsDenied, iterator_to_array($this->db->table("ChandlerACLUsersPermissions")->where("user", $this->user->getId())));
$permissionsAllowed = $permissionsAllowed->where("status", true);
foreach($permissionsAllowed as $perm) {
foreach($permissionsDenied as $denied)
if($denied->model === $perm->model && $denied->context === $perm->context && $denied->permission === $perm->permission)
continue 2;
$pm = new Permission;
$pm->action = $perm->permission;
$pm->model = $perm->model;
$pm->context = $perm->context;
$pm->status = true;
$this->perms[] = $pm;
}
}
function getPermissions(): array
{
return $this->perms;
}
function hasPermission(Permission $pm): bool
{
foreach($this->perms as $perm)
if($perm->model === $pm->model && $perm->context === $pm->context && $perm->action === $pm->action)
return true;
return false;
}
}

182
chandler/Security/User.php Normal file
View File

@ -0,0 +1,182 @@
<?php declare(strict_types=1);
namespace Chandler\Security;
use Chandler\Database\DatabaseConnection;
use Chandler\Security\Authorization\Permissions;
use Chandler\Security\Authorization\PermissionBuilder;
use Nette\Database\Table\ActiveRow;
use Nette\Database\UniqueConstraintViolationException;
/**
* User class.
*
* @author kurotsun <celestine@vriska.ru>
*/
class User
{
/**
* @var \Nette\Database\Context DB Explorer
*/
private $db;
/**
* @var \Nette\Database\Table\ActiveRow ActiveRow that represents user
*/
private $user;
/**
* @var bool Does this user is not the one who is logged in, but substituted?
*/
private $tainted;
/**
* @param \Nette\Database\Table\ActiveRow $user ActiveRow that represents user
* @param bool $tainted Does this user is not the one who is logged in, but substituted?
*/
function __construct(ActiveRow $user, bool $tainted = false)
{
$this->db = DatabaseConnection::i()->getContext();
$this->user = $user;
$this->tainted = $tainted;
}
/**
* Computes hash for a password.
*
* @param string $password password
* @return string hash
*/
private function makeHash(string $password): string
{
$salt = openssl_random_pseudo_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
$hash = sodium_crypto_pwhash(
16,
$password,
$salt,
SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
);
return bin2hex($hash) . "$" . bin2hex($salt);
}
/**
* Get user's GUID.
*
* @return string GUID
*/
function getId(): string
{
return $this->user->id;
}
/**
* Get user's DB data as an array.
*
* @return array DB data in form of associative array
*/
function getAttributes(): array
{
return (array) $this->user;
}
/**
* Get Permission Manager object.
*
* @api
* @see \Chandler\Security\User::can
* @return \Chandler\Security\Authorization\Permissions Permission Manager
*/
function getPermissions(): Permissions
{
return new Permissions($this);
}
/**
* Get ActiveRow that represents user
*
* @return \Nette\Database\Table\ActiveRow ActiveRow
*/
function getRaw(): ActiveRow
{
return $this->user;
}
/**
* Checks if this user is not the one who is logged in, but substituted
*
* @return bool Does this user is not the one who is logged in, but substituted?
*/
function isTainted(): bool
{
return $this->tainted;
}
/**
* Begins to build permission for checking it's status using Permission Builder.
* To get permission status you should chain methods like this:
* $user->can('do something')->model('\app\Web\Models\MyModel')->whichBelongsTo(10);
* In this case whichBelongsTo will automatically build permission and check if user
* has it. If you need to build permission for something another use {@see \Chandler\Security\User::getPermissions}.
*
* @api
* @uses \Chandler\Security\Authorization\PermissionBuilder::can
* @return \Chandler\Security\Authorization\Permissions Permission Manager
*/
function can(string $action): PermissionBuilder
{
$pb = new PermissionBuilder($this->getPermissions());
return $pb->can($action);
}
/**
* Updates user password.
* If $oldPassword parameter is passed it will update password only if current
* user password (not the new one) matches $oldPassword.
*
* @api
* @param string $password New Password
* @param string|null $oldPassword Current Password
* @return bool False if token manipulation error has been thrown
*/
function updatePassword(string $password, ?string $oldPassword = NULL): bool
{
if(!is_null($oldPassword))
if(!Authenticator::verifyHash($oldPassword, $this->getRaw()->passwordHash))
return false;
$users = DatabaseConnection::i()->getContext()->table("ChandlerUsers");
$users->where("id", $this->getId())->update([
"passwordHash" => $this->makeHash($password),
]);
return true;
}
/**
* Creates new user if login has not been taken yet.
*
* @api
* @param string $login Login (usually an email)
* @param string $password Password
* @return self|null New user if successful, null otherwise
*/
static function create(string $login, string $password): ?User
{
$users = DatabaseConnection::i()->getContext()->table("ChandlerUsers");
$hash = self::makeHash($password);
try {
$users->insert([
"login" => $login,
"passwordHash" => $hash,
]);
$user = $users->where("login", $login)->fetch();
} catch(UniqueConstraintViolationException $ex) {
return null;
}
return new static($user);
}
}

View File

@ -0,0 +1,136 @@
<?php declare(strict_types=1);
namespace Chandler\Session;
use Chandler\Patterns\TSimpleSingleton;
use Firebase\JWT\JWT;
/**
* Session singleton.
*
* @author kurotsun <celestine@vriska.ru>
*/
class Session
{
/**
* @var array Associative array of session variables
*/
private $data;
/**
* @var string Web-portal secret key
*/
private $key;
/**
* @internal
*/
private function __construct()
{
$this->key = strtr(CHANDLER_ROOT_CONF["security"]["secret"], "-_", "+/");
if(!isset($_COOKIE["CHANDLERSESS"]))
$this->initSession();
else
$this->bootstrapData();
}
/**
* Sets CHANDLERSESS cookie to specified token.
*
* @internal
* @param string $token Token
* @return void
*/
private function setSessionCookie(string $token): void
{
setcookie(
"CHANDLERSESS",
$token,
time() + 60 * 60 * 24 * ((int) CHANDLER_ROOT_CONF["security"]["sessionDuration"]),
"/",
"",
false,
true
);
}
/**
* Calculates session token and sets session cookie value to it.
* This function skips empty keys.
*
* @internal
* @return void
*/
private function updateSessionCookie(): void
{
$this->data = array_filter($this->data, function($data) {
return !(is_null($data) && $data !== "");
});
$this->setSessionCookie(JWT::encode($this->data, ($this->key), "HS512"));
}
/**
* Initializes session cookie with empty stub and loads no data.
*
* @internal
* @return void
*/
private function initSession(): void
{
$token = JWT::encode([], ($this->key), "HS512");
$this->setSessionCookie($token);
$this->data = [];
}
/**
* Reads data from cookie.
* If cookie is corrupted, session terminates and starts again.
*
* @internal
* @uses \Chandler\Session\Session::initSession
* @return void
*/
private function bootstrapData(): void
{
try {
$this->data = (array) JWT::decode($_COOKIE["CHANDLERSESS"], ($this->key), ["HS512"]);
} catch(\Exception $ex) {
$this->initSession();
}
}
/**
* Gets session variable.
* May also set a variable if default value is present and
* setting keys to default is permitted.
*
* @api
* @param string $key Session variable name
* @param scalar $default Default value
* @param bool $ser Set variable to default value if no data is present
* @uses \Chandler\Session\Session::set
* @return scalar
*/
function get(string $key, $default = null, bool $set = false)
{
return $this->data[sha1($key)] ?? ($set ? $this->set($key, $default) : $default);
}
/**
* Sets session variable.
*
* @api
* @param string $key Session variable name
* @param scalar $value Value
* @return scalar Value
*/
function set(string $key, $value)
{
$this->data[sha1($key)] = $value;
$this->updateSessionCookie();
return $value;
}
use TSimpleSingleton;
}

View File

@ -0,0 +1,102 @@
<?php declare(strict_types=1);
namespace Chandler\Signaling;
use Chandler\Patterns\TSimpleSingleton;
/**
* Signal manager (singleton).
* Signals are events, that are meant to be recieved by end user.
*
* @author kurotsun <celestine@vriska.ru>
*/
class SignalManager
{
/**
* @var int Latest event timestamp.
*/
private $since;
/**
* @var \PDO PDO Connection to events SQLite DB.
*/
private $connection;
/**
* @internal
*/
private function __construct()
{
$this->since = time();
$this->connection = new \PDO(
'sqlite:' . CHANDLER_ROOT . '/tmp/events.bin',
null,
null,
[\PDO::ATTR_PERSISTENT => true]
);
$this->connection->query("CREATE TABLE IF NOT EXISTS pool(id INTEGER PRIMARY KEY AUTOINCREMENT, since INTEGER, for INTEGER, event TEXT);");
}
/**
* Waits for event for user with ID = $for.
* This function is blocking.
*
* @internal
* @param int $for User ID
* @return array|null Array of events if there are any, null otherwise
*/
private function eventFor(int $for): ?array
{
$since = $this->since - 1;
$statement = $this->connection->query("SELECT * FROM pool WHERE `for` = $for AND `since` > $since ORDER BY since DESC");
$event = $statement->fetch(\PDO::FETCH_LAZY);
if(!$event) return null;
$this->since = time();
return [$event->id, unserialize(hex2bin($event->event))];
}
/**
* Set ups listener.
* This function blocks the thread and calls $callback each time
* a signal is recieved for user with ID = $for
*
* @api
* @param \Closure $callback Callback
* @param int $for User ID
* @uses \Chandler\Signaling\SignalManager::eventFor
* @return void
*/
function listen(\Closure $callback, int $for): void
{
$this->since = time() - 1;
for($i = 0; $i < 25; $i++) {
sleep(1);
$event = $this->eventFor($for);
if(!$event) continue;
list($id, $evt) = $event;
$id = crc32($id);
$callback($evt, $id);
}
exit("[]");
}
/**
* Triggers event for user and sends signal to DB and listeners.
*
* @api
* @param object $event Event
* @param int $for User ID
* @return bool Success state
*/
function triggerEvent(object $event, int $for): bool
{
$event = bin2hex(serialize($event));
$since = time();
$this->connection->query("INSERT INTO pool VALUES (NULL, $since, $for, '$event')");
return true;
}
use TSimpleSingleton;
}

1913
chandler/bindata/mime.types Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,73 @@
<?php declare(strict_types=1);
function system_extension_mime_types(): array
{
# Returns the system MIME type mapping of extensions to MIME types, as defined in /etc/mime.types.
$out = [];
$file = fopen(__DIR__ . '/../bindata/mime.types', 'r');
while(($line = fgets($file)) !== false) {
$line = trim(preg_replace('/#.*/', '', $line));
if(!$line)
continue;
$parts = preg_split('/\s+/', $line);
if(count($parts) == 1)
continue;
$type = array_shift($parts);
foreach($parts as $part)
$out[$part] = $type;
}
fclose($file);
return $out;
}
function system_extension_mime_type(string $file): string
{
# Returns the system MIME type (as defined in /etc/mime.types) for the filename specified.
#
# $file - the filename to examine
$aliases = [
"apng" => "png",
];
static $types;
if(!isset($types))
$types = system_extension_mime_types();
$ext = pathinfo($file, PATHINFO_EXTENSION);
if(!$ext)
$ext = $file;
$ext = strtolower($ext);
$ext = $aliases[$ext] ?? $ext;
return isset($types[$ext]) ? $types[$ext] : null;
}
function system_mime_type_extensions(): array
{
# Returns the system MIME type mapping of MIME types to extensions, as defined in /etc/mime.types (considering the first
# extension listed to be canonical).
$out = [];
$file = fopen('/etc/mime.types', 'r');
while(($line = fgets($file)) !== false) {
$line = trim(preg_replace('/#.*/', '', $line));
if(!$line)
continue;
$parts = preg_split('/\s+/', $line);
if(count($parts) == 1)
continue;
$type = array_shift($parts);
if(!isset($out[$type]))
$out[$type] = array_shift($parts);
}
fclose($file);
return $out;
}
function system_mime_type_extension(string $type): string
{
# Returns the canonical file extension for the MIME type specified, as defined in /etc/mime.types (considering the first
# extension listed to be canonical).
#
# $type - the MIME type
static $exts;
if(!isset($exts))
$exts = system_mime_type_extensions();
return isset($exts[$type]) ? $exts[$type] : null;
}

View File

@ -0,0 +1,57 @@
<?php
/**
* Ends application and renders error page.
*
* @api
* @author kurotsun <celestine@vriska.ru>
* @param int $code HTTP Error code
* @param string $description HTTP Error description
* @param string $message Additional message to show to client
* @return void
*/
function chandler_http_panic(int $code = 400, string $description = "Bad Request", string $message = ""): void
{
$error = <<<EOE
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8" />
<style>
body,h1,h2,h3{margin:0;}
body{font-family:sans-serif;background-color:#cee3ef;}
fieldset{border-color:#73716b;}
legend{font-weight:900;background-color:#e7eff7;border:2px solid #e0e0e0;border-bottom:2px solid #949694;}
h2,h3{color:#ce0000;}
h2{margin-bottom:10px;}
#header,#subheader_Server{color:#fff;padding:20px;}
#header{background-color:#5a86b5;border-bottom:1px solid #4a6d8c;}
#subheader_Server{background-color:#5a7da5;text-align:right;border-bottom: 1px solid #c6cfde;}
.container{margin:20px;padding:10px;background-color:#fff;}
</style>
<title></title>
</head>
<body>
<div id="header">
<h1>Server error</h1>
</div>
<div id="subheader_Server">
libchandler
</div>
<div class="container">
<fieldset>
<legend>Error summary</legend>
<h2>HTTP Error $code.0 - $description</h2>
<h3>$message</h3>
</fieldset>
</div>
</body>
</html>
EOE;
header("HTTP/1.0 $code $description");
exit($error);
}

View File

@ -0,0 +1,34 @@
<?php
use Nette\Caching\Storages\FileStorage;
use Nette\Caching\Cache;
$GLOBALS["ymlCaFS"] = new FileStorage(CHANDLER_ROOT . "/tmp/cache/yaml");
$GLOBALS["ymlCa"] = new Cache($GLOBALS["ymlCaFS"]);
/**
* Parses YAML from file.
* Caches result on disk to enhance speed.
* Developers are encouraged to use this function for parsing their YAML data.
*
* @api
* @author kurotsun <celestine@vriska.ru>
* @param string $filename Path to file
* @return array Array
*/
function chandler_parse_yaml(string $filename): array
{
$cache = $GLOBALS["ymlCa"];
$id = sha1($filename);
$result = $cache->load($id);
if(!$result) {
$result = yaml_parse_file($filename);
$cache->save($id, $result, [
Cache::EXPIRE => "1 day",
Cache::SLIDING => TRUE,
Cache::FILES => $filename,
]);
}
return $result;
}

17
composer.json Normal file
View File

@ -0,0 +1,17 @@
{
"require": {
"php": "~7.3",
"ext-yaml": "*",
"nette/utils": "^3.0",
"nette/di": "^3.0",
"nette/database": "^3.0",
"swiftmailer/swiftmailer": "^6.2",
"latte/latte": "2.6.2",
"nette/safe-stream": "^2.4",
"nette/tokenizer": "^3.1",
"firebase/php-jwt": "^5.0",
"tracy/tracy": "^2.7",
"symfony/translation": "^5.0"
}
}

1544
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

0
docs/.gitkeep Normal file
View File

View File

View File

10
htdocs/.htaccess Normal file
View File

@ -0,0 +1,10 @@
RewriteEngine On
RewriteRule ^(.+)-datastore/(.+) datafwd.php?extension=$1&file=$2 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule (.*) index.php [QSA]
php_flag display_errors on
php_flag display_startup_errors on

7
htdocs/index.php Normal file
View File

@ -0,0 +1,7 @@
<?php declare(strict_types=1);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL & ~E_DEPRECATED);
$bootstrap = require("../chandler/Bootstrap.php");
$bootstrap->ignite();

0
tmp/cache/database/.gitkeep vendored Normal file
View File

0
tmp/cache/templates/.gitkeep vendored Normal file
View File

0
tmp/cache/yaml/.gitkeep vendored Normal file
View File

BIN
tmp/events.bin Normal file

Binary file not shown.

View File