mirror of
https://github.com/openvk/chandler.git
synced 2024-11-14 19:19:11 +03:00
Initial commit
This commit is contained in:
commit
bda6f5faf2
52 changed files with 5631 additions and 0 deletions
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
* text=auto
|
||||
*.* text eol=lf
|
||||
|
||||
*.dat binary
|
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
BIN
3rdparty/maxmind/GeoIP.dat
vendored
Normal file
Binary file not shown.
13
COPYING
Normal file
13
COPYING
Normal 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
23
INSTALL.md
Normal 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
9
README.md
Normal 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
18
chandler-example.yml
Normal 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
140
chandler/Bootstrap.php
Normal 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;
|
13
chandler/ControlPanel/includes/assert_user.php
Normal file
13
chandler/ControlPanel/includes/assert_user.php
Normal 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");
|
||||
}
|
||||
});
|
3
chandler/ControlPanel/includes/common_init.php
Normal file
3
chandler/ControlPanel/includes/common_init.php
Normal file
|
@ -0,0 +1,3 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
(require(__DIR__ . "/assert_user.php"))();
|
11
chandler/ControlPanel/includes/verify_user.php
Normal file
11
chandler/ControlPanel/includes/verify_user.php
Normal 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);
|
||||
});
|
50
chandler/ControlPanel/users/al_api_info.phtml
Normal file
50
chandler/ControlPanel/users/al_api_info.phtml
Normal 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);
|
102
chandler/Database/DBEntity.php
Normal file
102
chandler/Database/DBEntity.php
Normal 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;
|
||||
}
|
82
chandler/Database/DatabaseConnection.php
Normal file
82
chandler/Database/DatabaseConnection.php
Normal 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];
|
||||
}
|
||||
}
|
41
chandler/Debug/DatabasePanel.php
Normal file
41
chandler/Debug/DatabasePanel.php
Normal 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;
|
||||
}
|
||||
}
|
1
chandler/Debug/templates/db-icon.svg
Normal file
1
chandler/Debug/templates/db-icon.svg
Normal 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
24
chandler/Email/Email.php
Normal 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);
|
||||
}
|
||||
}
|
33
chandler/Eventing/EventDispatcher.php
Normal file
33
chandler/Eventing/EventDispatcher.php
Normal 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;
|
||||
}
|
11
chandler/Eventing/Events/Cancelable.php
Normal file
11
chandler/Eventing/Events/Cancelable.php
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace Chandler\Eventing\Events;
|
||||
|
||||
interface Cancelable
|
||||
{
|
||||
protected $cancelled;
|
||||
|
||||
function cancel(): void;
|
||||
|
||||
function isCancelled(): bool;
|
||||
}
|
37
chandler/Eventing/Events/Event.php
Normal file
37
chandler/Eventing/Events/Event.php
Normal 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;
|
||||
}
|
||||
}
|
120
chandler/Extensions/ExtensionManager.php
Normal file
120
chandler/Extensions/ExtensionManager.php
Normal 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;
|
||||
}
|
5
chandler/MVC/Exceptions/InterruptedException.php
Normal file
5
chandler/MVC/Exceptions/InterruptedException.php
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace Chandler\MVC\Exceptions;
|
||||
|
||||
final class InterruptedException extends \Exception
|
||||
{}
|
15
chandler/MVC/IPresenter.php
Normal file
15
chandler/MVC/IPresenter.php
Normal 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;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace Chandler\MVC\Routing\Exceptions;
|
||||
|
||||
final class UnknownTypeAliasException extends \Exception
|
||||
{}
|
11
chandler/MVC/Routing/Route.php
Normal file
11
chandler/MVC/Routing/Route.php
Normal 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;
|
||||
}
|
272
chandler/MVC/Routing/Router.php
Normal file
272
chandler/MVC/Routing/Router.php
Normal 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;
|
||||
}
|
207
chandler/MVC/SimplePresenter.php
Normal file
207
chandler/MVC/SimplePresenter.php
Normal 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;
|
||||
}
|
34
chandler/Patterns/ActiveRecord.php.old
Normal file
34
chandler/Patterns/ActiveRecord.php.old
Normal 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()
|
||||
}
|
16
chandler/Patterns/TSimpleSingleton.php
Normal file
16
chandler/Patterns/TSimpleSingleton.php
Normal 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;
|
||||
}
|
||||
}
|
114
chandler/Security/Authenticator.php
Normal file
114
chandler/Security/Authenticator.php
Normal 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;
|
||||
}
|
17
chandler/Security/Authorization/Permission.php
Normal file
17
chandler/Security/Authorization/Permission.php
Normal 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;
|
||||
}
|
43
chandler/Security/Authorization/PermissionBuilder.php
Normal file
43
chandler/Security/Authorization/PermissionBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
61
chandler/Security/Authorization/Permissions.php
Normal file
61
chandler/Security/Authorization/Permissions.php
Normal 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
182
chandler/Security/User.php
Normal 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);
|
||||
}
|
||||
}
|
136
chandler/Session/Session.php
Normal file
136
chandler/Session/Session.php
Normal 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;
|
||||
}
|
102
chandler/Signaling/SignalManager.php
Normal file
102
chandler/Signaling/SignalManager.php
Normal 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
1913
chandler/bindata/mime.types
Normal file
File diff suppressed because it is too large
Load diff
73
chandler/procedural/mimes.php
Normal file
73
chandler/procedural/mimes.php
Normal 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;
|
||||
}
|
57
chandler/procedural/panic.php
Normal file
57
chandler/procedural/panic.php
Normal 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);
|
||||
}
|
34
chandler/procedural/yaml.php
Normal file
34
chandler/procedural/yaml.php
Normal 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
17
composer.json
Normal 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
1544
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
0
docs/.gitkeep
Normal file
0
docs/.gitkeep
Normal file
0
extensions/available/.gitkeep
Normal file
0
extensions/available/.gitkeep
Normal file
0
extensions/enabled/.gitkeep
Normal file
0
extensions/enabled/.gitkeep
Normal file
10
htdocs/.htaccess
Normal file
10
htdocs/.htaccess
Normal 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
7
htdocs/index.php
Normal 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
0
tmp/cache/database/.gitkeep
vendored
Normal file
0
tmp/cache/templates/.gitkeep
vendored
Normal file
0
tmp/cache/templates/.gitkeep
vendored
Normal file
0
tmp/cache/yaml/.gitkeep
vendored
Normal file
0
tmp/cache/yaml/.gitkeep
vendored
Normal file
BIN
tmp/events.bin
Normal file
BIN
tmp/events.bin
Normal file
Binary file not shown.
0
tmp/plugins-artifacts/.gitkeep
Normal file
0
tmp/plugins-artifacts/.gitkeep
Normal file
Loading…
Reference in a new issue