Twig, Tests, AES,

This commit is contained in:
Roman 2021-12-08 16:53:43 +01:00
parent 25d47f7528
commit 918244125c
74 changed files with 5350 additions and 1515 deletions

122
cli.php

@ -203,7 +203,7 @@ function handleDatabase(array $argv) {
// 2nd: delete! // 2nd: delete!
foreach ($tables as $table => $uids) { foreach ($tables as $table => $uids) {
$success = $sql->delete($table) $success = $sql->delete($table)
->where(new CondIn("uid", $uids)) ->where(new CondIn(new Column("uid"), $uids))
->execute(); ->execute();
if (!$success) { if (!$success) {
@ -342,6 +342,18 @@ function onMaintenance(array $argv) {
} }
} }
function getConsoleWidth(): int {
$width = getenv('COLUMNS');
if (!$width) {
$width = exec('tput cols');
if (!$width) {
$width = 80; // default gnome-terminal column count
}
}
return intval($width);
}
function printTable(array $head, array $body) { function printTable(array $head, array $body) {
$columns = []; $columns = [];
@ -349,6 +361,7 @@ function printTable(array $head, array $body) {
$columns[$key] = strlen($key); $columns[$key] = strlen($key);
} }
$maxWidth = getConsoleWidth();
foreach ($body as $row) { foreach ($body as $row) {
foreach ($head as $key) { foreach ($head as $key) {
$value = $row[$key] ?? ""; $value = $row[$key] ?? "";
@ -364,14 +377,61 @@ function printTable(array $head, array $body) {
printLine(); printLine();
foreach ($body as $row) { foreach ($body as $row) {
$line = 0;
foreach ($head as $key) { foreach ($head as $key) {
echo str_pad($row[$key] ?? "", $columns[$key]) . ' '; $width = min(max($maxWidth - $line, 0), $columns[$key]);
$line += $width;
echo str_pad($row[$key] ?? "", $width) . ' ';
} }
printLine(); printLine();
} }
} }
// TODO: add missing api functions (should be all internal only i guess) function onSettings(array $argv) {
$user = getUser() or die();
$action = $argv[2] ?? "list";
if ($action === "list" || $action === "get") {
$key = (($action === "list" || count($argv) < 4) ? null : $argv[3]);
$req = new Api\Settings\Get($user);
$success = $req->execute(["key" => $key]);
if (!$success) {
_exit("Error listings settings: " . $req->getLastError());
} else {
$settings = [];
foreach ($req->getResult()["settings"] as $key => $value) {
$settings[] = ["key" => $key, "value" => $value];
}
printTable(["key", "value"], $settings);
}
} else if ($action === "set" || $action === "update") {
if (count($argv) < 5) {
_exit("Usage: $argv[0] settings $argv[2] <key> <value>");
} else {
$key = $argv[3];
$value = $argv[4];
$req = new Api\Settings\Set($user);
$success = $req->execute(["settings" => [$key => $value]]);
if (!$success) {
_exit("Error updating settings: " . $req->getLastError());
}
}
} else if ($action === "unset" || $action === "delete") {
if (count($argv) < 4) {
_exit("Usage: $argv[0] settings $argv[2] <key>");
} else {
$key = $argv[3];
$req = new Api\Settings\Set($user);
$success = $req->execute(["settings" => [$key => null]]);
if (!$success) {
_exit("Error updating settings: " . $req->getLastError());
}
}
} else {
_exit("Usage: $argv[0] settings <get|set|unset>");
}
}
function onRoutes(array $argv) { function onRoutes(array $argv) {
$user = getUser() or die(); $user = getUser() or die();
@ -459,7 +519,60 @@ function onRoutes(array $argv) {
} }
function onTest($argv) { function onTest($argv) {
$files = glob(WEBROOT . '/test/*.test.php');
$requestedTests = array_filter(array_slice($argv, 2), function ($t) {
return !startsWith($t, "-");
});
$verbose = in_array("-v", $requestedTests);
foreach ($files as $file) {
include_once $file;
$baseName = substr(basename($file), 0, - strlen(".test.php"));
if (!empty($requestedTests) && !in_array($baseName, $requestedTests)) {
continue;
}
$className = $baseName . "Test";
if (class_exists($className)) {
echo "=== Running $className ===" . PHP_EOL;
$testClass = new \PHPUnit\Framework\TestSuite();
$testClass->addTestSuite($className);
$result = $testClass->run();
echo "Done after " . $result->time() . "s" . PHP_EOL;
$stats = [
"total" => $result->count(),
"skipped" => $result->skippedCount(),
"error" => $result->errorCount(),
"failure" => $result->failureCount(),
"warning" => $result->warningCount(),
];
// Summary
echo implode(", ", array_map(function ($key) use ($stats) {
return "$key: " . $stats[$key];
}, array_keys($stats))) . PHP_EOL;
$reports = array_merge($result->errors(), $result->failures());
foreach ($reports as $error) {
$exception = $error->thrownException();
echo $error->toString();
if ($verbose) {
echo ". Stacktrace:" . PHP_EOL . $exception->getTraceAsString() . PHP_EOL;
} else {
$location = array_filter($exception->getTrace(), function ($t) use ($file) {
return isset($t["file"]) && $t["file"] === $file;
});
$location = array_reverse($location);
$location = array_pop($location);
if ($location) {
echo " in " . substr($location["file"], strlen(WEBROOT)) . "#" . $location["line"] . PHP_EOL;
} else {
echo PHP_EOL;
}
}
}
}
}
} }
function onMail($argv) { function onMail($argv) {
@ -507,6 +620,9 @@ switch ($command) {
case 'mail': case 'mail':
onMail($argv); onMail($argv);
break; break;
case 'settings':
onSettings($argv);
break;
default: default:
printLine("Unknown command '$command'"); printLine("Unknown command '$command'");
printLine(); printLine();

@ -38,9 +38,14 @@ namespace Api\Mail {
use Api\MailAPI; use Api\MailAPI;
use Api\Parameter\Parameter; use Api\Parameter\Parameter;
use Api\Parameter\StringType; use Api\Parameter\StringType;
use DateTimeInterface;
use Driver\SQL\Column\Column; use Driver\SQL\Column\Column;
use Driver\SQL\Condition\Compare; use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\CondBool;
use Driver\SQL\Condition\CondIn; use Driver\SQL\Condition\CondIn;
use Driver\SQL\Condition\CondNot;
use Driver\SQL\Expression\CurrentTimeStamp;
use Driver\SQL\Expression\JsonArrayAgg;
use Driver\SQL\Strategy\UpdateStrategy; use Driver\SQL\Strategy\UpdateStrategy;
use External\PHPMailer\Exception; use External\PHPMailer\Exception;
use External\PHPMailer\PHPMailer; use External\PHPMailer\PHPMailer;
@ -100,6 +105,14 @@ namespace Api\Mail {
$subject = $this->getParam('subject'); $subject = $this->getParam('subject');
$replyTo = $this->getParam('replyTo'); $replyTo = $this->getParam('replyTo');
$replyName = $this->getParam('replyName'); $replyName = $this->getParam('replyName');
$body = $this->getParam('body');
if (stripos($body, "<body") === false) {
$body = "<body>$body</body>";
}
if (stripos($body, "<html") === false) {
$body = "<html>$body</html>";
}
try { try {
$mail = new PHPMailer; $mail = new PHPMailer;
@ -119,9 +132,9 @@ namespace Api\Mail {
$mail->Username = $mailConfig->getLogin(); $mail->Username = $mailConfig->getLogin();
$mail->Password = $mailConfig->getPassword(); $mail->Password = $mailConfig->getPassword();
$mail->SMTPSecure = 'tls'; $mail->SMTPSecure = 'tls';
$mail->IsHTML(true);
$mail->CharSet = 'UTF-8'; $mail->CharSet = 'UTF-8';
$mail->Body = $this->getParam('body'); $mail->msgHTML($body);
$mail->AltBody = strip_tags($body);
$this->success = @$mail->Send(); $this->success = @$mail->Send();
if (!$this->success) { if (!$this->success) {
@ -212,7 +225,7 @@ namespace Api\Mail {
if ($this->success && count($entityIds) > 0) { if ($this->success && count($entityIds) > 0) {
$sql->update("EntityLog") $sql->update("EntityLog")
->set("modified", $sql->now()) ->set("modified", $sql->now())
->where(new CondIn("entityId", $entityIds)) ->where(new CondIn(new Column("entityId"), $entityIds))
->execute(); ->execute();
} }

@ -11,6 +11,7 @@ namespace Api\Notifications {
use Api\NotificationsAPI; use Api\NotificationsAPI;
use Api\Parameter\Parameter; use Api\Parameter\Parameter;
use Api\Parameter\StringType; use Api\Parameter\StringType;
use Driver\SQL\Column\Column;
use Driver\SQL\Condition\Compare; use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\CondIn; use Driver\SQL\Condition\CondIn;
use Driver\SQL\Query\Select; use Driver\SQL\Query\Select;
@ -252,7 +253,7 @@ namespace Api\Notifications {
if ($this->success) { if ($this->success) {
$res = $sql->update("GroupNotification") $res = $sql->update("GroupNotification")
->set("seen", true) ->set("seen", true)
->where(new CondIn("group_id", ->where(new CondIn(new Column("group_id"),
$sql->select("group_id") $sql->select("group_id")
->from("UserGroup") ->from("UserGroup")
->where(new Compare("user_id", $this->user->getId())))) ->where(new Compare("user_id", $this->user->getId()))))

@ -61,8 +61,9 @@ namespace Api\Permission {
return true; return true;
} }
if (!$this->user->isLoggedIn() || empty(array_intersect($groups, array_keys($this->user->getGroups())))) { $userGroups = $this->user->getGroups();
header('HTTP 1.1 401 Unauthorized'); if (empty($userGroups) || empty(array_intersect($groups, array_keys($this->user->getGroups())))) {
http_response_code(401);
return $this->createError("Permission denied."); return $this->createError("Permission denied.");
} }
} }
@ -197,7 +198,7 @@ namespace Api\Permission {
if ($this->success) { if ($this->success) {
$res = $sql->delete("ApiPermission") $res = $sql->delete("ApiPermission")
->where(new Compare("description", "")) // only delete non default permissions ->where(new Compare("description", "")) // only delete non default permissions
->where(new CondNot(new CondIn("method", $insertedMethods))) ->where(new CondNot(new CondIn(new Column("method"), $insertedMethods)))
->execute(); ->execute();
$this->success = ($res !== FALSE); $this->success = ($res !== FALSE);

@ -45,6 +45,17 @@ class Request {
} }
} }
protected function allowMethod($method) {
$availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"];
if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) {
$this->allowedMethods[] = $method;
}
}
protected function getRequestMethod() {
return $_SERVER["REQUEST_METHOD"];
}
public function parseParams($values, $structure = NULL): bool { public function parseParams($values, $structure = NULL): bool {
if ($structure === NULL) { if ($structure === NULL) {
@ -80,6 +91,11 @@ class Request {
} }
} }
// wrapper for unit tests
protected function _die(string $data = ""): bool {
die($data);
}
public function execute($values = array()): bool { public function execute($values = array()): bool {
$this->params = array_merge([], $this->defaultParams); $this->params = array_merge([], $this->defaultParams);
$this->success = false; $this->success = false;
@ -98,7 +114,7 @@ class Request {
$values = array_merge($values, $jsonData); $values = array_merge($values, $jsonData);
} else { } else {
$this->lastError = 'Invalid request body.'; $this->lastError = 'Invalid request body.';
header('HTTP 1.1 400 Bad Request'); http_response_code(400);
return false; return false;
} }
} }
@ -106,39 +122,48 @@ class Request {
if ($this->isDisabled) { if ($this->isDisabled) {
$this->lastError = "This function is currently disabled."; $this->lastError = "This function is currently disabled.";
http_response_code(503);
return false; return false;
} }
if ($this->externalCall && !$this->isPublic) { if ($this->externalCall && !$this->isPublic) {
$this->lastError = 'This function is private.'; $this->lastError = 'This function is private.';
header('HTTP 1.1 403 Forbidden'); http_response_code(403);
return false; return false;
} }
if ($this->externalCall) { if ($this->externalCall) {
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); # No content
header("Allow: OPTIONS, " . implode(", ", $this->allowedMethods));
return $this->_die();
}
// check the request method // check the request method
if (!in_array($_SERVER['REQUEST_METHOD'], $this->allowedMethods)) { if (!in_array($_SERVER['REQUEST_METHOD'], $this->allowedMethods)) {
$this->lastError = 'This method is not allowed'; $this->lastError = 'This method is not allowed';
header('HTTP 1.1 405 Method Not Allowed'); http_response_code(405);
return false; return false;
} }
$apiKeyAuthorized = false; $apiKeyAuthorized = false;
// Logged in or api key authorized? if (!$this->user->isLoggedIn() && $this->apiKeyAllowed) {
if ($this->loginRequired) { if (isset($_SERVER["HTTP_AUTHORIZATION"])) {
if (isset($_SERVER["HTTP_AUTHORIZATION"]) && $this->apiKeyAllowed) {
$authHeader = $_SERVER["HTTP_AUTHORIZATION"]; $authHeader = $_SERVER["HTTP_AUTHORIZATION"];
if (startsWith($authHeader, "Bearer ")) { if (startsWith($authHeader, "Bearer ")) {
$apiKey = substr($authHeader, strlen("Bearer ")); $apiKey = substr($authHeader, strlen("Bearer "));
$apiKeyAuthorized = $this->user->authorize($apiKey); $apiKeyAuthorized = $this->user->authorize($apiKey);
} }
} }
}
// Logged in or api key authorized?
if ($this->loginRequired) {
if (!$this->user->isLoggedIn() && !$apiKeyAuthorized) { if (!$this->user->isLoggedIn() && !$apiKeyAuthorized) {
$this->lastError = 'You are not logged in.'; $this->lastError = 'You are not logged in.';
header('HTTP 1.1 401 Unauthorized'); http_response_code(401);
return false; return false;
} }
} }
@ -149,7 +174,7 @@ class Request {
// if it's not a call with API_KEY, check for csrf_token // if it's not a call with API_KEY, check for csrf_token
if (!isset($values["csrf_token"]) || strcmp($values["csrf_token"], $this->user->getSession()->getCsrfToken()) !== 0) { if (!isset($values["csrf_token"]) || strcmp($values["csrf_token"], $this->user->getSession()->getCsrfToken()) !== 0) {
$this->lastError = "CSRF-Token mismatch"; $this->lastError = "CSRF-Token mismatch";
header('HTTP 1.1 403 Forbidden'); http_response_code(403);
return false; return false;
} }
} }
@ -235,9 +260,6 @@ class Request {
} }
protected function disableOutputBuffer() { protected function disableOutputBuffer() {
header('X-Accel-Buffering: no');
header("Cache-Control: no-transform, no-store, max-age=0");
ob_implicit_flush(true); ob_implicit_flush(true);
$levels = ob_get_level(); $levels = ob_get_level();
for ( $i = 0; $i < $levels; $i ++ ) { for ( $i = 0; $i < $levels; $i ++ ) {
@ -245,4 +267,84 @@ class Request {
} }
flush(); flush();
} }
protected function setupSSE() {
$this->user->getSQL()->close();
$this->user->sendCookies();
set_time_limit(0);
ignore_user_abort(true);
header('Content-Type: text/event-stream');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
header('Cache-Control: no-cache');
$this->disableOutputBuffer();
}
protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null) {
if (empty($_FILES)) {
return $this->createError("You need to upload an image.");
} else if (count($_FILES) > 1) {
return $this->createError("You can only upload one image at once.");
}
$upload = array_values($_FILES)[0];
if (is_array($upload["name"])) {
return $this->createError("You can only upload one image at once.");
} else if ($upload["error"] !== UPLOAD_ERR_OK) {
return $this->createError("There was an error uploading the image, code: " . $upload["error"]);
}
$imageName = $upload["name"];
$ext = strtolower(pathinfo($imageName, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExtensions)) {
return $this->createError("Only the following file extensions are allowed: " . implode(",", $allowedExtensions));
}
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0777, true)) {
return $this->createError("Upload directory does not exist and could not be created.");
}
$srcPath = $upload["tmp_name"];
$mimeType = mime_content_type($srcPath);
if (!startsWith($mimeType, "image/")) {
return $this->createError("Uploaded file is not an image.");
}
try {
$image = new \Imagick($srcPath);
// strip exif
$profiles = $image->getImageProfiles("icc", true);
$image->stripImage();
if (!empty($profiles)) {
$image->profileImage("icc", $profiles['icc']);
}
} catch (\ImagickException $ex) {
return $this->createError("Error loading image: " . $ex->getMessage());
}
try {
if ($transformCallback) {
$fileName = call_user_func([$this, $transformCallback], $image, $uploadDir);
} else {
$image->writeImage($srcPath);
$image->destroy();
$uuid = uuidv4();
$fileName = "$uuid.$ext";
$destPath = "$uploadDir/$fileName";
if (!file_exists($destPath)) {
if (!@move_uploaded_file($srcPath, $destPath)) {
return $this->createError("Could not store uploaded file.");
}
}
}
return [$fileName, $imageName];
} catch (\ImagickException $ex) {
return $this->createError("Error processing image: " . $ex->getMessage());
}
}
} }

@ -141,7 +141,7 @@ namespace Api\Settings {
$res = $sql->select("name") $res = $sql->select("name")
->from("Settings") ->from("Settings")
->where(new CondBool("readonly")) ->where(new CondBool("readonly"))
->where(new CondIn("name", $keys)) ->where(new CondIn(new Column("name"), $keys))
->limit(1) ->limit(1)
->execute(); ->execute();
@ -158,7 +158,7 @@ namespace Api\Settings {
private function deleteKeys(array $keys) { private function deleteKeys(array $keys) {
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$res = $sql->delete("Settings") $res = $sql->delete("Settings")
->where(new CondIn("name", $keys)) ->where(new CondIn(new Column("name"), $keys))
->execute(); ->execute();
$this->success = ($res !== FALSE); $this->success = ($res !== FALSE);

@ -0,0 +1,78 @@
<?php
namespace Api {
use Objects\User;
abstract class TemplateAPI extends Request {
function __construct(User $user, bool $externalCall = false, array $params = array()) {
parent::__construct($user, $externalCall, $params);
$this->isPublic = false; // internal API
}
}
}
namespace Api\Template {
use Api\Parameter\ArrayType;
use Api\Parameter\Parameter;
use Api\Parameter\StringType;
use Api\TemplateAPI;
use Objects\User;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Loader\FilesystemLoader;
class Render extends TemplateAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"file" => new StringType("file"),
"parameters" => new ArrayType("parameters", Parameter::TYPE_MIXED, false, true, [])
]);
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$templateFile = $this->getParam("file");
$parameters = $this->getParam("parameters");
$extension = pathinfo($templateFile, PATHINFO_EXTENSION);
$allowedExtensions = ["html", "twig"];
if (!in_array($extension, $allowedExtensions)) {
return $this->createError("Invalid template file extension. Allowed: " . implode(",", $allowedExtensions));
}
$templateDir = WEBROOT . "/core/Templates/";
$templateCache = WEBROOT . "/core/TemplateCache/";
$path = realpath($templateDir . $templateFile);
if (!startsWith($path, realpath($templateDir))) {
return $this->createError("Template file not in template directory");
} else if (!is_file($path)) {
return $this->createError("Template file not found");
}
$twigLoader = new FilesystemLoader($templateDir);
$twigEnvironment = new Environment($twigLoader, [
'cache' => $templateCache,
'auto_reload' => true
]);
try {
$this->result["html"] = $twigEnvironment->render($templateFile, $parameters);
} catch (LoaderError | RuntimeError | SyntaxError $e) {
return $this->createError("Error rendering twig template: " . $e->getMessage());
}
return $this->success;
}
}
}

@ -6,7 +6,7 @@ namespace Api {
abstract class UserAPI extends Request { abstract class UserAPI extends Request {
protected function userExists(?string $username, ?string $email = null) { protected function userExists(?string $username, ?string $email = null): bool {
$conditions = array(); $conditions = array();
if ($username) { if ($username) {
@ -42,8 +42,8 @@ namespace Api {
return $this->success; return $this->success;
} }
protected function checkPasswordRequirements($password, $confirmPassword) { protected function checkPasswordRequirements($password, $confirmPassword): bool {
if(strcmp($password, $confirmPassword) !== 0) { if ((($password === null) !== ($confirmPassword === null)) || strcmp($password, $confirmPassword) !== 0) {
return $this->createError("The given passwords do not match"); return $this->createError("The given passwords do not match");
} else if(strlen($password) < 6) { } else if(strlen($password) < 6) {
return $this->createError("The password should be at least 6 characters long"); return $this->createError("The password should be at least 6 characters long");
@ -91,7 +91,8 @@ namespace Api {
protected function getUser($id) { protected function getUser($id) {
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$res = $sql->select("User.uid as userId", "User.name", "User.email", "User.registered_at", "User.confirmed", $res = $sql->select("User.uid as userId", "User.name", "User.fullName", "User.email",
"User.registered_at", "User.confirmed", "User.last_online", "User.profilePicture",
"Group.uid as groupId", "Group.name as groupName", "Group.color as groupColor") "Group.uid as groupId", "Group.name as groupName", "Group.color as groupColor")
->from("User") ->from("User")
->leftJoin("UserGroup", "User.uid", "UserGroup.user_id") ->leftJoin("UserGroup", "User.uid", "UserGroup.user_id")
@ -105,24 +106,6 @@ namespace Api {
return ($this->success && !empty($res) ? $res : array()); return ($this->success && !empty($res) ? $res : array());
} }
protected function getMessageTemplate($key) {
$req = new \Api\Settings\Get($this->user);
$this->success = $req->execute(array("key" => "^($key|mail_enabled)$"));
$this->lastError = $req->getLastError();
if ($this->success) {
$settings = $req->getResult()["settings"];
$isEnabled = ($settings["mail_enabled"] ?? "0") === "1";
if (!$isEnabled) {
return $this->createError("Mail is not enabled.");
}
return $settings[$key] ?? "{{link}}";
}
return $this->success;
}
protected function invalidateToken($token) { protected function invalidateToken($token) {
$this->user->getSQL() $this->user->getSQL()
->update("UserToken") ->update("UserToken")
@ -142,6 +125,14 @@ namespace Api {
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
return $this->success; return $this->success;
} }
protected function formatDuration(int $count, string $string): string {
if ($count === 1) {
return $string;
} else {
return "the next $count ${string}s";
}
}
} }
} }
@ -150,12 +141,17 @@ namespace Api\User {
use Api\Parameter\Parameter; use Api\Parameter\Parameter;
use Api\Parameter\StringType; use Api\Parameter\StringType;
use Api\Template\Render;
use Api\UserAPI; use Api\UserAPI;
use Api\VerifyCaptcha; use Api\VerifyCaptcha;
use DateTime; use DateTime;
use Driver\SQL\Column\Column;
use Driver\SQL\Condition\Compare; use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\CondBool; use Driver\SQL\Condition\CondBool;
use Driver\SQL\Condition\CondIn; use Driver\SQL\Condition\CondIn;
use Driver\SQL\Condition\CondNot;
use Driver\SQL\Expression\JsonArrayAgg;
use ImagickException;
use Objects\User; use Objects\User;
class Create extends UserAPI { class Create extends UserAPI {
@ -239,10 +235,10 @@ namespace Api\User {
$this->success = ($res !== NULL); $this->success = ($res !== NULL);
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
if ($this->success) { if ($this->success && is_array($res)) {
$ids = array(); return array_map(function ($row) {
foreach($res as $row) $ids[] = $row["uid"]; return intval($row["uid"]);
return $ids; }, $res);
} }
return false; return false;
@ -274,11 +270,12 @@ namespace Api\User {
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$res = $sql->select("User.uid as userId", "User.name", "User.email", "User.registered_at", "User.confirmed", $res = $sql->select("User.uid as userId", "User.name", "User.email", "User.registered_at", "User.confirmed",
"Group.uid as groupId", "Group.name as groupName", "Group.color as groupColor") "User.profilePicture", "User.fullName", "Group.uid as groupId", "User.last_online",
"Group.name as groupName", "Group.color as groupColor")
->from("User") ->from("User")
->leftJoin("UserGroup", "User.uid", "UserGroup.user_id") ->leftJoin("UserGroup", "User.uid", "UserGroup.user_id")
->leftJoin("Group", "Group.uid", "UserGroup.group_id") ->leftJoin("Group", "Group.uid", "UserGroup.group_id")
->where(new CondIn("User.uid", $userIds)) ->where(new CondIn(new Column("User.uid"), $userIds))
->execute(); ->execute();
$this->success = ($res !== FALSE); $this->success = ($res !== FALSE);
@ -291,15 +288,29 @@ namespace Api\User {
$groupId = intval($row["groupId"]); $groupId = intval($row["groupId"]);
$groupName = $row["groupName"]; $groupName = $row["groupName"];
$groupColor = $row["groupColor"]; $groupColor = $row["groupColor"];
$fullInfo = ($userId === $this->user->getId()) ||
($this->user->hasGroup(USER_GROUP_ADMIN) || $this->user->hasGroup(USER_GROUP_SUPPORT));
if (!isset($this->result["users"][$userId])) { if (!isset($this->result["users"][$userId])) {
$this->result["users"][$userId] = array( $user = array(
"uid" => $userId, "uid" => $userId,
"name" => $row["name"], "name" => $row["name"],
"fullName" => $row["fullName"],
"profilePicture" => $row["profilePicture"],
"email" => $row["email"], "email" => $row["email"],
"registered_at" => $row["registered_at"],
"confirmed" => $sql->parseBool($row["confirmed"]), "confirmed" => $sql->parseBool($row["confirmed"]),
"groups" => array(), "groups" => array(),
); );
if ($fullInfo) {
$user["registered_at"] = $row["registered_at"];
$user["last_online"] = $row["last_online"];
} else if (!$sql->parseBool($row["confirmed"])) {
continue;
}
$this->result["users"][$userId] = $user;
} }
if (!is_null($groupId)) { if (!is_null($groupId)) {
@ -323,6 +334,7 @@ namespace Api\User {
parent::__construct($user, $externalCall, array( parent::__construct($user, $externalCall, array(
'id' => new Parameter('id', Parameter::TYPE_INT) 'id' => new Parameter('id', Parameter::TYPE_INT)
)); ));
$this->loginRequired = true;
} }
public function execute($values = array()): bool { public function execute($values = array()): bool {
@ -331,30 +343,79 @@ namespace Api\User {
} }
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$id = $this->getParam("id"); $userId = $this->getParam("id");
$user = $this->getUser($id); $user = $this->getUser($userId);
if ($this->success) { if ($this->success) {
if (empty($user)) { if (empty($user)) {
return $this->createError("User not found"); return $this->createError("User not found");
} else { } else {
$this->result["user"] = array(
"uid" => $user[0]["userId"], $queriedUser = array(
"uid" => $userId,
"name" => $user[0]["name"], "name" => $user[0]["name"],
"fullName" => $user[0]["fullName"],
"email" => $user[0]["email"], "email" => $user[0]["email"],
"registered_at" => $user[0]["registered_at"], "registered_at" => $user[0]["registered_at"],
"last_online" => $user[0]["last_online"],
"profilePicture" => $user[0]["profilePicture"],
"confirmed" => $sql->parseBool($user["0"]["confirmed"]), "confirmed" => $sql->parseBool($user["0"]["confirmed"]),
"groups" => array() "groups" => array(),
); );
foreach($user as $row) { foreach($user as $row) {
if (!is_null($row["groupId"])) { if (!is_null($row["groupId"])) {
$this->result["user"]["groups"][$row["groupId"]] = array( $queriedUser["groups"][$row["groupId"]] = array(
"name" => $row["groupName"], "name" => $row["groupName"],
"color" => $row["groupColor"], "color" => $row["groupColor"],
); );
} }
} }
// either we are querying own info or we are internal employees
// as internal employees can add arbitrary users to projects
$canView = ($userId === $this->user->getId() ||
$this->user->hasGroup(USER_GROUP_ADMIN) ||
$this->user->hasGroup(USER_GROUP_SUPPORT));
// full info only when we have administrative privileges, or we are querying ourselves
$fullInfo = ($userId === $this->user->getId()) ||
($this->user->hasGroup(USER_GROUP_ADMIN) || $this->user->hasGroup(USER_GROUP_SUPPORT));
if (!$canView) {
$res = $sql->select(new JsonArrayAgg(new Column("projectId"), "projectIds"))
->from("ProjectMember")
->where(new Compare("userId", $this->user->getId()), new Compare("userId", $userId))
->groupBy("projectId")
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success ) {
return false;
} else if (is_array($res)) {
foreach ($res as $row) {
if (count(json_decode($row["projectIds"])) > 1) {
$canView = true;
break;
}
}
}
}
if (!$canView) {
return $this->createError("No permissions to access this user");
}
if (!$fullInfo) {
if (!$queriedUser["confirmed"]) {
return $this->createError("No permissions to access this user");
}
unset($queriedUser["registered_at"]);
unset($queriedUser["confirmed"]);
unset($queriedUser["last_online"]);
}
$this->result["user"] = $queriedUser;
} }
} }
@ -424,11 +485,6 @@ namespace Api\User {
return false; return false;
} }
$messageBody = $this->getMessageTemplate("message_accept_invite");
if ($messageBody === false) {
return false;
}
// Create user // Create user
$id = $this->insertUser($username, $email, "", false); $id = $this->insertUser($username, $email, "", false);
if (!$this->success) { if (!$this->success) {
@ -437,7 +493,8 @@ namespace Api\User {
// Create Token // Create Token
$token = generateRandomString(36); $token = generateRandomString(36);
$valid_until = (new DateTime())->modify("+7 day"); $validDays = 7;
$valid_until = (new DateTime())->modify("+$validDays day");
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until")) $res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until"))
->addRow($id, $token, "invite", $valid_until) ->addRow($id, $token, "invite", $valid_until)
@ -449,29 +506,34 @@ namespace Api\User {
if ($this->success) { if ($this->success) {
$settings = $this->user->getConfiguration()->getSettings(); $settings = $this->user->getConfiguration()->getSettings();
$baseUrl = htmlspecialchars($settings->getBaseUrl()); $baseUrl = $settings->getBaseUrl();
$siteName = htmlspecialchars($settings->getSiteName()); $siteName = $settings->getSiteName();
$replacements = array( $req = new Render($this->user);
"link" => "$baseUrl/acceptInvite?token=$token", $this->success = $req->execute([
"site_name" => $siteName, "file" => "mail/accept_invite.twig",
"base_url" => $baseUrl, "parameters" => [
"username" => htmlspecialchars($username) "link" => "$baseUrl/acceptInvite?token=$token",
); "site_name" => $siteName,
"base_url" => $baseUrl,
"username" => $username,
"valid_time" => $this->formatDuration($validDays, "day")
]
]);
$this->lastError = $req->getLastError();
foreach($replacements as $key => $value) { if ($this->success) {
$messageBody = str_replace("{{{$key}}}", $value, $messageBody); $messageBody = $req->getResult()["html"];
$request = new \Api\Mail\Send($this->user);
$this->success = $request->execute(array(
"to" => $email,
"subject" => "[$siteName] Account Invitation",
"body" => $messageBody
));
$this->lastError = $request->getLastError();
} }
$request = new \Api\Mail\Send($this->user);
$this->success = $request->execute(array(
"to" => $email,
"subject" => "[$siteName] Account Invitation",
"body" => $messageBody
));
$this->lastError = $request->getLastError();
if (!$this->success) { if (!$this->success) {
$this->lastError = "The invitation was created but the confirmation email could not be sent. " . $this->lastError = "The invitation was created but the confirmation email could not be sent. " .
"Please contact the server administration. Reason: " . $this->lastError; "Please contact the server administration. Reason: " . $this->lastError;
@ -607,7 +669,7 @@ namespace Api\User {
public function __construct($user, $externalCall = false) { public function __construct($user, $externalCall = false) {
parent::__construct($user, $externalCall, array( parent::__construct($user, $externalCall, array(
'username' => new StringType('username', 32), 'username' => new StringType('username'),
'password' => new StringType('password'), 'password' => new StringType('password'),
'stayLoggedIn' => new Parameter('stayLoggedIn', Parameter::TYPE_BOOLEAN, true, true) 'stayLoggedIn' => new Parameter('stayLoggedIn', Parameter::TYPE_BOOLEAN, true, true)
)); ));
@ -641,7 +703,8 @@ namespace Api\User {
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$res = $sql->select("User.uid", "User.password", "User.confirmed") $res = $sql->select("User.uid", "User.password", "User.confirmed")
->from("User") ->from("User")
->where(new Compare("User.name", $username)) ->where(new Compare("User.name", $username), new Compare("User.email", $username))
->limit(1)
->execute(); ->execute();
$this->success = ($res !== FALSE); $this->success = ($res !== FALSE);
@ -753,35 +816,38 @@ namespace Api\User {
return false; return false;
} }
$messageBody = $this->getMessageTemplate("message_confirm_email");
if ($messageBody === false) {
return false;
}
$this->userId = $this->insertUser($username, $email, $password, false); $this->userId = $this->insertUser($username, $email, $password, false);
if (!$this->success) { if (!$this->success) {
return false; return false;
} }
// add internal group
$this->user->getSQL()->insert("UserGroup", ["user_id", "group_id"])
->addRow($this->userId, USER_GROUP_INTERNAL)
->execute();
$validHours = 48;
$this->token = generateRandomString(36); $this->token = generateRandomString(36);
if ($this->insertToken($this->userId, $this->token, "email_confirm", 48)) { if ($this->insertToken($this->userId, $this->token, "email_confirm", $validHours)) {
$settings = $this->user->getConfiguration()->getSettings(); $settings = $this->user->getConfiguration()->getSettings();
$baseUrl = htmlspecialchars($settings->getBaseUrl()); $baseUrl = $settings->getBaseUrl();
$siteName = htmlspecialchars($settings->getSiteName()); $siteName = $settings->getSiteName();
$req = new Render($this->user);
if ($this->success) { $this->success = $req->execute([
"file" => "mail/confirm_email.twig",
$replacements = array( "parameters" => [
"link" => "$baseUrl/confirmEmail?token=$this->token", "link" => "$baseUrl/confirmEmail?token=$this->token",
"site_name" => $siteName, "site_name" => $siteName,
"base_url" => $baseUrl, "base_url" => $baseUrl,
"username" => htmlspecialchars($username) "username" => $username,
); "valid_time" => $this->formatDuration($validHours, "hour")
]
foreach($replacements as $key => $value) { ]);
$messageBody = str_replace("{{{$key}}}", $value, $messageBody); $this->lastError = $req->getLastError();
}
if ($this->success) {
$messageBody = $req->getResult()["html"];
$request = new \Api\Mail\Send($this->user); $request = new \Api\Mail\Send($this->user);
$this->success = $request->execute(array( $this->success = $request->execute(array(
"to" => $email, "to" => $email,
@ -862,6 +928,7 @@ namespace Api\User {
parent::__construct($user, $externalCall, array( parent::__construct($user, $externalCall, array(
'id' => new Parameter('id', Parameter::TYPE_INT), 'id' => new Parameter('id', Parameter::TYPE_INT),
'username' => new StringType('username', 32, true, NULL), 'username' => new StringType('username', 32, true, NULL),
'fullName' => new StringType('fullName', 64, true, NULL),
'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL), 'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL),
'password' => new StringType('password', -1, true, NULL), 'password' => new StringType('password', -1, true, NULL),
'groups' => new Parameter('groups', Parameter::TYPE_ARRAY, true, NULL), 'groups' => new Parameter('groups', Parameter::TYPE_ARRAY, true, NULL),
@ -886,6 +953,7 @@ namespace Api\User {
} }
$username = $this->getParam("username"); $username = $this->getParam("username");
$fullName = $this->getParam("fullName");
$email = $this->getParam("email"); $email = $this->getParam("email");
$password = $this->getParam("password"); $password = $this->getParam("password");
$groups = $this->getParam("groups"); $groups = $this->getParam("groups");
@ -913,6 +981,7 @@ namespace Api\User {
// Check for duplicate username, email // Check for duplicate username, email
$usernameChanged = !is_null($username) && strcasecmp($username, $user[0]["name"]) !== 0; $usernameChanged = !is_null($username) && strcasecmp($username, $user[0]["name"]) !== 0;
$fullNameChanged = !is_null($fullName) && strcasecmp($fullName, $user[0]["fullName"]) !== 0;
$emailChanged = !is_null($email) && strcasecmp($email, $user[0]["email"]) !== 0; $emailChanged = !is_null($email) && strcasecmp($email, $user[0]["email"]) !== 0;
if($usernameChanged || $emailChanged) { if($usernameChanged || $emailChanged) {
if (!$this->userExists($usernameChanged ? $username : NULL, $emailChanged ? $email : NULL)) { if (!$this->userExists($usernameChanged ? $username : NULL, $emailChanged ? $email : NULL)) {
@ -924,6 +993,7 @@ namespace Api\User {
$query = $sql->update("User"); $query = $sql->update("User");
if ($usernameChanged) $query->set("name", $username); if ($usernameChanged) $query->set("name", $username);
if ($fullNameChanged) $query->set("fullName", $fullName);
if ($emailChanged) $query->set("email", $email); if ($emailChanged) $query->set("email", $email);
if (!is_null($password)) $query->set("password", $this->hashPassword($password)); if (!is_null($password)) $query->set("password", $this->hashPassword($password));
@ -1028,50 +1098,51 @@ namespace Api\User {
} }
} }
$messageBody = $this->getMessageTemplate("message_reset_password");
if ($messageBody === false) {
return false;
}
$email = $this->getParam("email"); $email = $this->getParam("email");
$user = $this->findUser($email); $user = $this->findUser($email);
if ($user === false) { if ($this->success === false) {
return false; return false;
} }
if ($user !== null) { if ($user !== null) {
$validHours = 1;
$token = generateRandomString(36); $token = generateRandomString(36);
if (!$this->insertToken($user["uid"], $token, "password_reset", 1)) { if (!$this->insertToken($user["uid"], $token, "password_reset", $validHours)) {
return false; return false;
} }
$baseUrl = htmlspecialchars($settings->getBaseUrl()); $baseUrl = $settings->getBaseUrl();
$siteName = htmlspecialchars($settings->getSiteName()); $siteName = $settings->getSiteName();
$replacements = array( $req = new Render($this->user);
"link" => "$baseUrl/resetPassword?token=$token", $this->success = $req->execute([
"site_name" => $siteName, "file" => "mail/reset_password.twig",
"base_url" => $baseUrl, "parameters" => [
"username" => htmlspecialchars($user["name"]) "link" => "$baseUrl/resetPassword?token=$token",
); "site_name" => $siteName,
"base_url" => $baseUrl,
"username" => $user["name"],
"valid_time" => $this->formatDuration($validHours, "hour")
]
]);
$this->lastError = $req->getLastError();
foreach($replacements as $key => $value) { if ($this->success) {
$messageBody = str_replace("{{{$key}}}", $value, $messageBody); $messageBody = $req->getResult()["html"];
$request = new \Api\Mail\Send($this->user);
$this->success = $request->execute(array(
"to" => $email,
"subject" => "[$siteName] Password Reset",
"body" => $messageBody
));
$this->lastError = $request->getLastError();
} }
$request = new \Api\Mail\Send($this->user);
$this->success = $request->execute(array(
"to" => $email,
"subject" => "[$siteName] Password Reset",
"body" => $messageBody
));
$this->lastError = $request->getLastError();
} }
return $this->success; return $this->success;
} }
private function findUser($email) { private function findUser($email): ?array {
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$res = $sql->select("User.uid", "User.name") $res = $sql->select("User.uid", "User.name")
->from("User") ->from("User")
@ -1082,14 +1153,12 @@ namespace Api\User {
$this->success = ($res !== FALSE); $this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
if ($this->success) { if ($this->success) {
if (empty($res)) { if (!empty($res)) {
return null;
} else {
return $res[0]; return $res[0];
} }
} }
return $this->success; return null;
} }
} }
@ -1125,11 +1194,6 @@ namespace Api\User {
} }
} }
$messageBody = $this->getMessageTemplate("message_confirm_email");
if ($messageBody === false) {
return false;
}
$email = $this->getParam("email"); $email = $this->getParam("email");
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$res = $sql->select("User.uid", "User.name", "UserToken.token", "UserToken.token_type", "UserToken.used") $res = $sql->select("User.uid", "User.name", "UserToken.token", "UserToken.token_type", "UserToken.used")
@ -1157,36 +1221,49 @@ namespace Api\User {
})) }))
); );
$validHours = 48;
if (!$token) { if (!$token) {
// no token generated yet, let's generate one // no token generated yet, let's generate one
$token = generateRandomString(36); $token = generateRandomString(36);
if (!$this->insertToken($userId, $token, "email_confirm", 48)) { if (!$this->insertToken($userId, $token, "email_confirm", $validHours)) {
return false; return false;
} }
} else {
$sql->update("UserToken")
->set("valid_until", (new DateTime())->modify("+$validHours hour"))
->where(new Compare("token", $token))
->execute();
} }
$username = $res[0]["name"]; $username = $res[0]["name"];
$baseUrl = htmlspecialchars($settings->getBaseUrl()); $baseUrl = $settings->getBaseUrl();
$siteName = htmlspecialchars($settings->getSiteName()); $siteName = $settings->getSiteName();
$replacements = array(
"link" => "$baseUrl/confirmEmail?token=$token",
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => htmlspecialchars($username)
);
foreach($replacements as $key => $value) { $req = new Render($this->user);
$messageBody = str_replace("{{{$key}}}", $value, $messageBody); $this->success = $req->execute([
"file" => "mail/confirm_email.twig",
"parameters" => [
"link" => "$baseUrl/confirmEmail?token=$token",
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => $username,
"valid_time" => $this->formatDuration($validHours, "hour")
]
]);
$this->lastError = $req->getLastError();
if ($this->success) {
$messageBody = $req->getResult()["html"];
$request = new \Api\Mail\Send($this->user);
$this->success = $request->execute(array(
"to" => $email,
"subject" => "[$siteName] E-Mail Confirmation",
"body" => $messageBody
));
$this->lastError = $request->getLastError();
} }
$request = new \Api\Mail\Send($this->user);
$this->success = $request->execute(array(
"to" => $email,
"subject" => "[$siteName] E-Mail Confirmation",
"body" => $messageBody
));
$this->lastError = $request->getLastError();
return $this->success; return $this->success;
} }
} }
@ -1203,7 +1280,7 @@ namespace Api\User {
$this->csrfTokenRequired = false; $this->csrfTokenRequired = false;
} }
private function updateUser($uid, $password) { private function updateUser($uid, $password): bool {
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$res = $sql->update("User") $res = $sql->update("User")
->set("password", $this->hashPassword($password)) ->set("password", $this->hashPassword($password))
@ -1254,7 +1331,10 @@ namespace Api\User {
public function __construct(User $user, bool $externalCall = false) { public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, array( parent::__construct($user, $externalCall, array(
'username' => new StringType('username', 32, true, NULL), 'username' => new StringType('username', 32, true, NULL),
'fullName' => new StringType('fullName', 64, true, NULL),
'password' => new StringType('password', -1, true, NULL), 'password' => new StringType('password', -1, true, NULL),
'confirmPassword' => new StringType('confirmPassword', -1, true, NULL),
'oldPassword' => new StringType('oldPassword', -1, true, NULL),
)); ));
$this->loginRequired = true; $this->loginRequired = true;
$this->csrfTokenRequired = true; $this->csrfTokenRequired = true;
@ -1267,14 +1347,17 @@ namespace Api\User {
} }
$newUsername = $this->getParam("username"); $newUsername = $this->getParam("username");
$oldPassword = $this->getParam("oldPassword");
$newPassword = $this->getParam("password"); $newPassword = $this->getParam("password");
$newPasswordConfirm = $this->getParam("confirmPassword");
$newFullName = $this->getParam("fullName");
if ($newUsername === null && $newPassword === null) { if ($newUsername === null && $newPassword === null && $newPasswordConfirm === null && $newFullName === null) {
return $this->createError("You must either provide an updated username or password"); return $this->createError("You must either provide an updated username, fullName or password");
} }
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
$query = $sql->update("User")->where(new Compare("id", $this->user->getId())); $query = $sql->update("User")->where(new Compare("uid", $this->user->getId()));
if ($newUsername !== null) { if ($newUsername !== null) {
if (!$this->checkUsernameRequirements($newUsername) || $this->userExists($newUsername)) { if (!$this->checkUsernameRequirements($newUsername) || $this->userExists($newUsername)) {
return false; return false;
@ -1283,10 +1366,29 @@ namespace Api\User {
} }
} }
if ($newPassword !== null) { // TODO: confirm password? if ($newFullName !== null) {
if (!$this->checkPasswordRequirements($newPassword, $newPassword)) { $query->set("fullName", $newFullName);
}
if ($newPassword !== null || $newPasswordConfirm !== null) {
if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) {
return false; return false;
} else { } else {
$res = $sql->select("password")
->from("User")
->where(new Compare("uid", $this->user->getId()))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
if (!password_verify($oldPassword, $res[0]["password"])) {
return $this->createError("Wrong password");
}
$query->set("password", $this->hashPassword($newPassword)); $query->set("password", $this->hashPassword($newPassword));
} }
} }
@ -1296,4 +1398,152 @@ namespace Api\User {
return $this->success; return $this->success;
} }
} }
class UploadPicture extends UserAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"scale" => new Parameter("scale", Parameter::TYPE_FLOAT, true, NULL),
]);
$this->loginRequired = true;
$this->forbidMethod("GET");
}
/**
* @throws ImagickException
*/
protected function onTransform(\Imagick $im, $uploadDir) {
$minSize = 75;
$maxSize = 500;
$width = $im->getImageWidth();
$height = $im->getImageHeight();
$doResize = false;
if ($width < $minSize || $height < $minSize) {
if ($width < $height) {
$newWidth = $minSize;
$newHeight = intval(($minSize / $width) * $height);
} else {
$newHeight = $minSize;
$newWidth = intval(($minSize / $height) * $width);
}
$doResize = true;
} else if ($width > $maxSize || $height > $maxSize) {
if ($width > $height) {
$newWidth = $maxSize;
$newHeight = intval($height * ($maxSize / $width));
} else {
$newHeight = $maxSize;
$newWidth = intval($width * ($maxSize / $height));
}
$doResize = true;
} else {
$newWidth = $width;
$newHeight = $height;
}
if ($width < $minSize || $height < $minSize) {
return $this->createError("Error processing image. Bad dimensions.");
}
if ($doResize) {
$width = $newWidth;
$height = $newHeight;
$im->resizeImage($width, $height, \Imagick::FILTER_SINC, 1);
}
$size = $this->getParam("size");
if (is_null($size)) {
$size = min($width, $height);
}
$offset = [$this->getParam("offsetX"), $this->getParam("offsetY")];
if ($size < $minSize or $size > $maxSize) {
return $this->createError("Invalid size. Must be in range of $minSize-$maxSize.");
}/* else if ($offset[0] < 0 || $offset[1] < 0 || $offset[0]+$size > $width || $offset[1]+$size > $height) {
return $this->createError("Offsets out of bounds.");
}*/
if ($offset[0] !== 0 || $offset[1] !== 0 || $size !== $width || $size !== $height) {
$im->cropImage($size, $size, $offset[0], $offset[1]);
}
$fileName = uuidv4() . ".jpg";
$im->writeImage("$uploadDir/$fileName");
$im->destroy();
return $fileName;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$userId = $this->user->getId();
$uploadDir = WEBROOT . "/img/uploads/user/$userId";
list ($fileName, $imageName) = $this->processImageUpload($uploadDir, ["png","jpg","jpeg"], "onTransform");
if (!$this->success) {
return false;
}
$oldPfp = $this->user->getProfilePicture();
if ($oldPfp) {
$path = "$uploadDir/$oldPfp";
if (is_file($path)) {
@unlink($path);
}
}
$sql = $this->user->getSQL();
$this->success = $sql->update("User")
->set("profilePicture", $fileName)
->where(new Compare("uid", $userId))
->execute();
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["profilePicture"] = $fileName;
}
return $this->success;
}
}
class RemovePicture extends UserAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, []);
$this->loginRequired = true;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$pfp = $this->user->getProfilePicture();
if (!$pfp) {
return $this->createError("You did not upload a profile picture yet");
}
$userId = $this->user->getId();
$sql = $this->user->getSQL();
$this->success = $sql->update("User")
->set("profilePicture", NULL)
->where(new Compare("uid", $userId))
->execute();
$this->lastError = $sql->getLastError();
if ($this->success) {
$path = WEBROOT . "/img/uploads/user/$userId/$pfp";
if (is_file($path)) {
@unlink($path);
}
}
return $this->success;
}
}
} }

@ -57,6 +57,7 @@ class Settings {
if ($success) { if ($success) {
$result = $req->getResult()["settings"]; $result = $req->getResult()["settings"];
$this->siteName = $result["site_name"] ?? $this->siteName; $this->siteName = $result["site_name"] ?? $this->siteName;
$this->baseUrl = $result["base_url"] ?? $this->baseUrl;
$this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed; $this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed;
$this->installationComplete = $result["installation_completed"] ?? $this->installationComplete; $this->installationComplete = $result["installation_completed"] ?? $this->installationComplete;
$this->jwtSecret = $result["jwt_secret"] ?? $this->jwtSecret; $this->jwtSecret = $result["jwt_secret"] ?? $this->jwtSecret;

@ -1,74 +1,62 @@
<?php <?php
namespace Documents {
use Documents\Account\AccountBody; namespace Documents;
use Documents\Account\AccountHead;
use Elements\Document;
use Objects\User;
class Account extends Document { use Elements\TemplateDocument;
public function __construct(User $user, ?string $view) { use Objects\User;
parent::__construct($user, AccountHead::class, AccountBody::class, $view);
}
}
}
namespace Documents\Account {
use Elements\Head; class Account extends TemplateDocument {
use Elements\Link; public function __construct(User $user, ?string $template) {
use Elements\Script; parent::__construct($user, $template);
use Elements\SimpleBody; $this->enableCSP();
class AccountHead extends Head {
public function __construct($document) {
parent::__construct($document);
}
protected function initSources() {
$this->loadJQuery();
$this->addJS(Script::CORE);
$this->addJS(Script::ACCOUNT);
$this->loadBootstrap();
$this->loadFontawesome();
$this->addCSS(Link::CORE);
}
protected function initMetas(): array {
return array(
array('name' => 'viewport', 'content' => 'width=device-width, initial-scale=1.0'),
array('name' => 'format-detection', 'content' => 'telephone=yes'),
array('charset' => 'utf-8'),
array("http-equiv" => 'expires', 'content' => '0'),
array("name" => 'robots', 'content' => 'noarchive'),
);
}
protected function initRawFields(): array {
return array();
}
protected function initTitle(): string {
return "Account";
}
} }
class AccountBody extends SimpleBody { private function createError(string $message) {
$this->parameters["view"]["success"] = false;
$this->parameters["view"]["message"] = $message;
}
public function __construct($document) { protected function loadParameters() {
parent::__construct($document); $this->parameters["view"] = ["success" => true];
} if ($this->getTemplateName() === "account/reset_password.twig") {
if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
protected function getContent(): string { $this->parameters["view"]["token"] = $_GET["token"];
$req = new \Api\User\CheckToken($this->getUser());
$view = $this->getDocument()->getView(); $this->parameters["view"]["success"] = $req->execute(array("token" => $_GET["token"]));
if ($view === null) { if ($this->parameters["view"]["success"]) {
return "The page you does not exist or is no longer valid. <a href='/'>Return to start page</a>"; if (strcmp($req->getResult()["token"]["type"], "password_reset") !== 0) {
$this->createError("The given token has a wrong type.");
}
} else {
$this->createError("Error requesting password reset: " . $req->getLastError());
}
}
} else if ($this->getTemplateName() === "account/register.twig") {
$settings = $this->user->getConfiguration()->getSettings();
if ($this->user->isLoggedIn()) {
$this->createError("You are already logged in.");
} else if (!$settings->isRegistrationAllowed()) {
$this->createError("Registration is not enabled on this website.");
}
} else if ($this->getTemplateName() === "account/accept_invite.twig") {
if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
$this->parameters["view"]["token"] = $_GET["token"];
$req = new \Api\User\CheckToken($this->getUser());
$this->parameters["view"]["success"] = $req->execute(array("token" => $_GET["token"]));
if ($this->parameters["view"]["success"]) {
if (strcmp($req->getResult()["token"]["type"], "invite") !== 0) {
$this->createError("The given token has a wrong type.");
} else {
$this->parameters["view"]["invited_user"] = $req->getResult()["user"];
}
} else {
$this->createError("Error confirming e-mail address: " . $req->getLastError());
}
} else {
$this->createError("The link you visited is no longer valid");
} }
return $view->getCode();
} }
} }
} }

@ -1,51 +1,15 @@
<?php <?php
namespace Documents { namespace Documents;
use Documents\Admin\AdminHead; use Elements\TemplateDocument;
use Elements\Document; use Objects\User;
use Objects\User;
use Views\Admin\AdminDashboardBody;
use Views\Admin\LoginBody;
class Admin extends Document { class Admin extends TemplateDocument {
public function __construct(User $user, ?string $view = NULL) { public function __construct(User $user) {
$body = $user->isLoggedIn() ? AdminDashboardBody::class : LoginBody::class; $template = $user->isLoggedIn() ? "admin.twig" : "redirect.twig";
parent::__construct($user, AdminHead::class, $body, $view); $params = $user->isLoggedIn() ? [] : ["url" => "/login"];
} parent::__construct($user, $template, $params);
} $this->enableCSP();
}
namespace Documents\Admin {
use Elements\Head;
class AdminHead extends Head {
public function __construct($document) {
parent::__construct($document);
}
protected function initSources() {
$this->loadFontawesome();
}
protected function initMetas(): array {
return array(
array('name' => 'viewport', 'content' => 'width=device-width, initial-scale=1.0'),
array('name' => 'format-detection', 'content' => 'telephone=yes'),
array('charset' => 'utf-8'),
array("http-equiv" => 'expires', 'content' => '0'),
array("name" => 'robots', 'content' => 'noarchive'),
);
}
protected function initRawFields(): array {
return array();
}
protected function initTitle(): string {
return $this->getSiteName() . " - Administration";
}
} }
} }

@ -1,64 +1,18 @@
<?php <?php
namespace Documents { namespace Documents;
use Documents\Document404\Body404; use Elements\TemplateDocument;
use Documents\Document404\Head404; use Objects\User;
use Elements\Document;
class Document404 extends Document { class Document404 extends TemplateDocument {
public function __construct($user, ?string $view = NULL) {
parent::__construct($user, Head404::class, Body404::class, $view); public function __construct(User $user) {
} parent::__construct($user, "404.twig");
} }
}
public function loadParameters() {
namespace Documents\Document404 { parent::loadParameters();
http_response_code(404);
use Elements\Head;
use Elements\SimpleBody;
use Views\View404;
class Head404 extends Head {
public function __construct($document) {
parent::__construct($document);
}
protected function initSources() {
}
protected function initMetas(): array {
return array(
array('name' => 'viewport', 'content' => 'width=device-width, initial-scale=1.0'),
array('name' => 'format-detection', 'content' => 'telephone=yes'),
array('charset' => 'utf-8'),
array("http-equiv" => 'expires', 'content' => '0'),
array("name" => 'robots', 'content' => 'noarchive'),
);
}
protected function initRawFields(): array {
return array();
}
protected function initTitle(): string {
return "WebBase - Not Found";
}
}
class Body404 extends SimpleBody {
public function __construct($document) {
parent::__construct($document);
}
public function loadView() {
http_response_code(404);
}
protected function getContent(): string {
return $this->load(View404::class);
}
} }
} }

@ -4,9 +4,9 @@ namespace Documents {
use Documents\Install\InstallBody; use Documents\Install\InstallBody;
use Documents\Install\InstallHead; use Documents\Install\InstallHead;
use Elements\Document; use Elements\HtmlDocument;
class Install extends Document { class Install extends HtmlDocument {
public function __construct($user) { public function __construct($user) {
parent::__construct($user, InstallHead::class, InstallBody::class); parent::__construct($user, InstallHead::class, InstallBody::class);
$this->databaseRequired = false; $this->databaseRequired = false;

@ -0,0 +1,13 @@
<?php
namespace Driver\SQL\Query;
use Driver\SQL\Column\IntColumn;
class BigIntColumn extends IntColumn {
public function __construct(string $name, bool $nullable, $defaultValue, bool $unsigned) {
parent::__construct($name, $nullable, $defaultValue, $unsigned);
$this->type = "BIGINT";
}
}

@ -11,5 +11,9 @@ class EnumColumn extends Column {
$this->values = $values; $this->values = $values;
} }
public function addValues(string $value) {
$this->values[] = $value;
}
public function getValues(): array { return $this->values; } public function getValues(): array { return $this->values; }
} }

@ -4,8 +4,20 @@ namespace Driver\SQL\Column;
class IntColumn extends Column { class IntColumn extends Column {
public function __construct(string $name, bool $nullable = false, $defaultValue = NULL) { protected string $type;
private bool $unsigned;
public function __construct(string $name, bool $nullable = false, $defaultValue = NULL, bool $unsigned = false) {
parent::__construct($name, $nullable, $defaultValue); parent::__construct($name, $nullable, $defaultValue);
$this->type = "INTEGER";
$this->unsigned = $unsigned;
} }
public function isUnsigned(): bool {
return $this->unsigned;
}
public function getType(): string {
return $this->type;
}
} }

@ -4,14 +4,14 @@ namespace Driver\SQL\Condition;
class CondIn extends Condition { class CondIn extends Condition {
private string $column; private $needle;
private $expression; private $haystack;
public function __construct(string $column, $expression) { public function __construct($needle, $haystack) {
$this->column = $column; $this->needle = $needle;
$this->expression = $expression; $this->haystack = $haystack;
} }
public function getColumn(): string { return $this->column; } public function getNeedle() { return $this->needle; }
public function getExpression() { return $this->expression; } public function getHaystack() { return $this->haystack; }
} }

@ -0,0 +1,18 @@
<?php
namespace Driver\SQL\Expression;
class JsonArrayAgg extends Expression {
private $value;
private string $alias;
public function __construct($value, string $alias) {
$this->value = $value;
$this->alias = $alias;
}
public function getValue() { return $this->value; }
public function getAlias(): string { return $this->alias; }
}

@ -2,8 +2,6 @@
namespace Driver\SQL\Expression; namespace Driver\SQL\Expression;
use Driver\SQL\Condition\Condition;
class Sum extends Expression { class Sum extends Expression {
private $value; private $value;

@ -14,12 +14,12 @@ use \Driver\SQL\Column\DateTimeColumn;
use Driver\SQL\Column\BoolColumn; use Driver\SQL\Column\BoolColumn;
use Driver\SQL\Column\JsonColumn; use Driver\SQL\Column\JsonColumn;
use Driver\SQL\Condition\CondRegex;
use Driver\SQL\Expression\Add; use Driver\SQL\Expression\Add;
use Driver\SQL\Expression\CurrentTimeStamp; use Driver\SQL\Expression\CurrentTimeStamp;
use Driver\SQL\Expression\DateAdd; use Driver\SQL\Expression\DateAdd;
use Driver\SQL\Expression\DateSub; use Driver\SQL\Expression\DateSub;
use Driver\SQL\Expression\Expression; use Driver\SQL\Expression\Expression;
use Driver\SQL\Expression\JsonArrayAgg;
use Driver\SQL\Query\CreateProcedure; use Driver\SQL\Query\CreateProcedure;
use Driver\SQL\Query\CreateTrigger; use Driver\SQL\Query\CreateTrigger;
use Driver\SQL\Query\Query; use Driver\SQL\Query\Query;
@ -228,7 +228,8 @@ class MySQL extends SQL {
} else if($column instanceof SerialColumn) { } else if($column instanceof SerialColumn) {
return "INTEGER AUTO_INCREMENT"; return "INTEGER AUTO_INCREMENT";
} else if($column instanceof IntColumn) { } else if($column instanceof IntColumn) {
return "INTEGER"; $unsigned = $column->isUnsigned() ? " UNSIGNED" : "";
return $column->getType() . $unsigned;
} else if($column instanceof DateTimeColumn) { } else if($column instanceof DateTimeColumn) {
return "DATETIME"; return "DATETIME";
} else if($column instanceof BoolColumn) { } else if($column instanceof BoolColumn) {
@ -416,6 +417,10 @@ class MySQL extends SQL {
return "$dateFunction($lhs, INTERVAL $rhs $unit)"; return "$dateFunction($lhs, INTERVAL $rhs $unit)";
} else if ($exp instanceof CurrentTimeStamp) { } else if ($exp instanceof CurrentTimeStamp) {
return "NOW()"; return "NOW()";
} else if ($exp instanceof JsonArrayAgg) {
$value = $this->addValue($exp->getValue(), $params);
$alias = $this->columnName($exp->getAlias());
return "JSON_ARRAYAGG($value) as $alias";
} else { } else {
return parent::createExpression($exp, $params); return parent::createExpression($exp, $params);
} }

@ -20,6 +20,7 @@ use Driver\SQL\Expression\CurrentTimeStamp;
use Driver\SQL\Expression\DateAdd; use Driver\SQL\Expression\DateAdd;
use Driver\SQL\Expression\DateSub; use Driver\SQL\Expression\DateSub;
use Driver\SQL\Expression\Expression; use Driver\SQL\Expression\Expression;
use Driver\SQL\Expression\JsonArrayAgg;
use Driver\SQL\Query\CreateProcedure; use Driver\SQL\Query\CreateProcedure;
use Driver\SQL\Query\CreateTrigger; use Driver\SQL\Query\CreateTrigger;
use Driver\SQL\Query\Insert; use Driver\SQL\Query\Insert;
@ -219,7 +220,7 @@ class PostgreSQL extends SQL {
} else if($column instanceof SerialColumn) { } else if($column instanceof SerialColumn) {
return "SERIAL"; return "SERIAL";
} else if($column instanceof IntColumn) { } else if($column instanceof IntColumn) {
return "INTEGER"; return $column->getType();
} else if($column instanceof DateTimeColumn) { } else if($column instanceof DateTimeColumn) {
return "TIMESTAMP"; return "TIMESTAMP";
} else if($column instanceof EnumColumn) { } else if($column instanceof EnumColumn) {
@ -439,6 +440,10 @@ class PostgreSQL extends SQL {
return "$lhs $operator $rhs"; return "$lhs $operator $rhs";
} else if ($exp instanceof CurrentTimeStamp) { } else if ($exp instanceof CurrentTimeStamp) {
return "CURRENT_TIMESTAMP"; return "CURRENT_TIMESTAMP";
} else if ($exp instanceof JsonArrayAgg) {
$value = $this->addValue($exp->getValue(), $params);
$alias = $this->columnName($exp->getAlias());
return "JSON_AGG($value) as $alias";
} else { } else {
return parent::createExpression($exp, $params); return parent::createExpression($exp, $params);
} }

@ -3,15 +3,19 @@
namespace Driver\SQL\Query; namespace Driver\SQL\Query;
use Driver\SQL\Column\Column; use Driver\SQL\Column\Column;
use Driver\SQL\Column\EnumColumn;
use Driver\SQL\Constraint\Constraint; use Driver\SQL\Constraint\Constraint;
use Driver\SQL\Constraint\ForeignKey; use Driver\SQL\Constraint\ForeignKey;
use Driver\SQL\Constraint\PrimaryKey; use Driver\SQL\Constraint\PrimaryKey;
use Driver\SQL\MySQL;
use Driver\SQL\PostgreSQL;
use Driver\SQL\SQL; use Driver\SQL\SQL;
class AlterTable extends Query { class AlterTable extends Query {
private string $table; private string $table;
private string $action; private string $action;
private $data;
private ?Column $column; private ?Column $column;
private ?Constraint $constraint; private ?Constraint $constraint;
@ -59,6 +63,13 @@ class AlterTable extends Query {
return $this; return $this;
} }
public function addToEnum(EnumColumn $column, string $newValue): AlterTable {
$this->action = "MODIFY";
$this->column = $column;
$this->data = $newValue;
return $this;
}
public function getAction(): string { return $this->action; } public function getAction(): string { return $this->action; }
public function getColumn(): ?Column { return $this->column; } public function getColumn(): ?Column { return $this->column; }
public function getConstraint(): ?Constraint { return $this->constraint; } public function getConstraint(): ?Constraint { return $this->constraint; }
@ -82,6 +93,15 @@ class AlterTable extends Query {
$query .= $this->sql->columnName($column->getName()); $query .= $this->sql->columnName($column->getName());
} else { } else {
// ADD or modify // ADD or modify
if ($column instanceof EnumColumn) {
if ($this->sql instanceof PostgreSQL) {
$typeName = $this->sql->getColumnType($column);
$value = $this->sql->addValue($this->data, $params);
return "ALTER TYPE $typeName ADD VALUE $value";
}
$column->addValue($this->data);
}
$query .= $this->sql->getColumnDefinition($column); $query .= $this->sql->getColumnDefinition($column);
} }
} else if ($constraint) { } else if ($constraint) {

@ -46,8 +46,13 @@ class CreateTable extends Query {
return $this; return $this;
} }
public function addInt(string $name, bool $nullable = false, $defaultValue = NULL): CreateTable { public function addInt(string $name, bool $nullable = false, $defaultValue = NULL, bool $unsigned = false): CreateTable {
$this->columns[$name] = new IntColumn($name, $nullable, $defaultValue); $this->columns[$name] = new IntColumn($name, $nullable, $defaultValue, $unsigned);
return $this;
}
public function addBigInt(string $name, bool $nullable = false, $defaultValue = NULL, bool $unsigned = false): CreateTable {
$this->columns[$name] = new BigIntColumn($name, $nullable, $defaultValue, $unsigned);
return $this; return $this;
} }

@ -278,22 +278,29 @@ abstract class SQL {
} }
} else if($condition instanceof CondIn) { } else if($condition instanceof CondIn) {
$expression = $condition->getExpression(); $needle = $condition->getNeedle();
if (is_array($expression)) { $haystack = $condition->getHaystack();
if (is_array($haystack)) {
$values = array(); $values = array();
foreach ($expression as $value) { foreach ($haystack as $value) {
$values[] = $this->addValue($value, $params); $values[] = $this->addValue($value, $params);
} }
$values = implode(",", $values); $values = implode(",", $values);
} else if($expression instanceof Select) { } else if($haystack instanceof Select) {
$values = $expression->build($params); $values = $haystack->build($params);
} else { } else {
$this->lastError = "Unsupported in-expression value: " . get_class($condition); $this->lastError = "Unsupported in-expression value: " . get_class($condition);
return false; return false;
} }
return $this->columnName($condition->getColumn()) . " IN ($values)"; if ($needle instanceof Column) {
$lhs = $this->createExpression($needle, $params);
} else {
$lhs = $this->addValue($needle, $params);
}
return "$lhs IN ($values)";
} else if($condition instanceof CondKeyword) { } else if($condition instanceof CondKeyword) {
$left = $condition->getLeftExp(); $left = $condition->getLeftExp();
$right = $condition->getRightExp(); $right = $condition->getRightExp();
@ -315,14 +322,14 @@ abstract class SQL {
} else if ($condition instanceof Exists) { } else if ($condition instanceof Exists) {
return "EXISTS(" .$condition->getSubQuery()->build($params) . ")"; return "EXISTS(" .$condition->getSubQuery()->build($params) . ")";
} else { } else {
$this->lastError = "Unsupported condition type: " . get_class($condition); $this->lastError = "Unsupported condition type: " . gettype($condition);
return null; return null;
} }
} }
protected function createExpression(Expression $exp, array &$params): ?string { protected function createExpression(Expression $exp, array &$params): ?string {
if ($exp instanceof Column) { if ($exp instanceof Column) {
return $this->columnName($exp); return $this->columnName($exp->getName());
} else if ($exp instanceof Query) { } else if ($exp instanceof Query) {
return "(" . $exp->build($params) . ")"; return "(" . $exp->build($params) . ")";
} else if ($exp instanceof CaseWhen) { } else if ($exp instanceof CaseWhen) {
@ -335,7 +342,7 @@ abstract class SQL {
return "CASE WHEN $condition THEN $trueCase ELSE $falseCase END"; return "CASE WHEN $condition THEN $trueCase ELSE $falseCase END";
} else if ($exp instanceof Sum) { } else if ($exp instanceof Sum) {
$value = $this->addValue($exp->getValue(), $params); $value = $this->addValue($exp->getValue(), $params);
$alias = $exp->getAlias(); $alias = $this->columnName($exp->getAlias());
return "SUM($value) AS $alias"; return "SUM($value) AS $alias";
} else { } else {
$this->lastError = "Unsupported expression type: " . get_class($exp); $this->lastError = "Unsupported expression type: " . get_class($exp);

@ -2,69 +2,64 @@
namespace Elements; namespace Elements;
use Driver\SQL\SQL;
use Objects\User; use Objects\User;
abstract class Document { abstract class Document {
protected Head $head;
protected Body $body;
protected User $user; protected User $user;
protected bool $databaseRequired; protected bool $databaseRequired;
private ?string $activeView; private bool $cspEnabled;
private ?string $cspNonce;
public function __construct(User $user, $headClass, $bodyClass, ?string $view = NULL) { public function __construct(User $user) {
$this->user = $user; $this->user = $user;
$this->head = new $headClass($this); $this->cspEnabled = false;
$this->body = new $bodyClass($this); $this->cspNonce = null;
$this->databaseRequired = true; $this->databaseRequired = true;
$this->activeView = $view;
} }
public function getHead(): Head { return $this->head; } public function getSQL(): ?SQL {
public function getBody(): Body { return $this->body; } return $this->user->getSQL();
public function getSQL(): ?\Driver\SQL\SQL { return $this->user->getSQL(); }
public function getUser(): User { return $this->user; }
public function getView() : ?View {
if ($this->activeView === null) {
return null;
}
$view = parseClass($this->activeView);
$file = getClassPath($view);
if(!file_exists($file) || !is_subclass_of($view, View::class)) {
return null;
}
return new $view($this);
} }
public function getRequestedView(): string { public function getUser(): User {
return $this->activeView; return $this->user;
} }
function getCode(): string { public function getCSPNonce(): ?string {
return $this->cspNonce;
}
public function isCSPEnabled(): bool {
return $this->cspEnabled;
}
public function enableCSP() {
$this->cspEnabled = true;
$this->cspNonce = generateRandomString(16, "base62");
}
public function getCode(): string {
if ($this->databaseRequired) { if ($this->databaseRequired) {
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
if (is_null($sql)) { if (is_null($sql)) {
die("Database is not configured yet."); die("Database is not configured yet.");
} else if(!$sql->isConnected()) { } else if (!$sql->isConnected()) {
die("Database is not connected: " . $sql->getLastError()); die("Database is not connected: " . $sql->getLastError());
} }
} }
$body = $this->body->getCode(); if ($this->cspEnabled) {
$head = $this->head->getCode(); $csp = ["default-src 'self'", "object-src 'none'", "base-uri 'self'", "style-src 'self' 'unsafe-inline'", "script-src 'nonce-$this->cspNonce'"];
$lang = $this->user->getLanguage()->getShortCode(); if ($this->user->getConfiguration()->getSettings()->isRecaptchaEnabled()) {
$csp[] = "frame-src https://www.google.com/ 'self'";
}
$html = "<!DOCTYPE html>"; $compiledCSP = implode(";", $csp);
$html .= "<html lang=\"$lang\">"; header("Content-Security-Policy: $compiledCSP;");
$html .= $head; }
$html .= $body;
$html .= "</html>"; return "";
return $html;
} }
} }

@ -0,0 +1,79 @@
<?php
namespace Elements;
use Objects\User;
class HtmlDocument extends Document {
protected Head $head;
protected Body $body;
private ?string $activeView;
public function __construct(User $user, $headClass, $bodyClass, ?string $view = NULL) {
parent::__construct($user);
$this->head = $headClass ? new $headClass($this) : null;
$this->body = $bodyClass ? new $bodyClass($this) : null;
$this->activeView = $view;
}
public function getHead(): Head { return $this->head; }
public function getBody(): Body { return $this->body; }
public function getView() : ?View {
if ($this->activeView === null) {
return null;
}
$view = parseClass($this->activeView);
$file = getClassPath($view);
if(!file_exists($file) || !is_subclass_of($view, View::class)) {
return null;
}
return new $view($this);
}
public function createScript($type, $src, $content = ""): Script {
$script = new Script($type, $src, $content);
if ($this->isCSPEnabled()) {
$script->setNonce($this->getCSPNonce());
}
return $script;
}
public function getRequestedView(): string {
return $this->activeView;
}
function getCode(): string {
parent::getCode();
// generate body first, so we can modify head
$body = $this->body->getCode();
if ($this->isCSPEnabled()) {
foreach ($this->head->getSources() as $element) {
if ($element instanceof Script || $element instanceof Link) {
$element->setNonce($this->getCSPNonce());
}
}
}
$head = $this->head->getCode();
$lang = $this->user->getLanguage()->getShortCode();
$html = "<!DOCTYPE html>";
$html .= "<html lang=\"$lang\">";
$html .= $head;
$html .= $body;
$html .= "</html>";
return $html;
}
}

@ -15,15 +15,30 @@ class Link extends StaticView {
private string $type; private string $type;
private string $rel; private string $rel;
private string $href; private string $href;
private ?string $nonce;
function __construct($rel, $href, $type = "") { function __construct($rel, $href, $type = "") {
$this->href = $href; $this->href = $href;
$this->type = $type; $this->type = $type;
$this->rel = $rel; $this->rel = $rel;
$this->nonce = null;
} }
function getCode(): string { function getCode(): string {
$type = (empty($this->type) ? "" : " type=\"$this->type\""); $attributes = ["rel" => $this->rel, "href" => $this->href];
return "<link rel=\"$this->rel\" href=\"$this->href\"$type/>";
if (!empty($this->type)) {
$attributes["type"] = $this->type;
}
if (!empty($this->nonce)) {
$attributes["nonce"] = $this->nonce;
}
$attributes = html_attributes($attributes);
return "<link $attributes/>";
}
public function setNonce(string $nonce) {
$this->nonce = $nonce;
} }
} }

@ -11,21 +11,35 @@ class Script extends StaticView {
const INSTALL = "/js/install.js"; const INSTALL = "/js/install.js";
const BOOTSTRAP = "/js/bootstrap.bundle.min.js"; const BOOTSTRAP = "/js/bootstrap.bundle.min.js";
const ACCOUNT = "/js/account.js"; const ACCOUNT = "/js/account.js";
const SECLAB = "/js/seclab.min.js";
const FONTAWESOME = "/js/fontawesome-all.min.js"; const FONTAWESOME = "/js/fontawesome-all.min.js";
private string $type; private string $type;
private string $content; private string $content;
private string $src; private string $src;
private ?string $nonce;
function __construct($type, $src, $content = "") { function __construct($type, $src, $content = "") {
$this->src = $src; $this->src = $src;
$this->type = $type; $this->type = $type;
$this->content = $content; $this->content = $content;
$this->nonce = null;
} }
function getCode(): string { function getCode(): string {
$src = (empty($this->src) ? "" : " src=\"$this->src\""); $attributes = ["type" => $this->type];
return "<script type=\"$this->type\"$src>$this->content</script>"; if (!empty($this->src)) {
$attributes["src"] = $this->src;
}
if (!empty($this->nonce)) {
$attributes["nonce"] = $this->nonce;
}
$attributes = html_attributes($attributes);
return "<script $attributes>$this->content</script>";
}
public function setNonce(string $nonce) {
$this->nonce = $nonce;
} }
} }

@ -0,0 +1,71 @@
<?php
namespace Elements;
use Objects\User;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Loader\FilesystemLoader;
class TemplateDocument extends Document {
private string $templateName;
protected array $parameters;
private Environment $twigEnvironment;
private FilesystemLoader $twigLoader;
public function __construct(User $user, string $templateName, array $initialParameters = []) {
parent::__construct($user);
$this->templateName = $templateName;
$this->parameters = $initialParameters;
$this->twigLoader = new FilesystemLoader(WEBROOT . '/core/Templates');
$this->twigEnvironment = new Environment($this->twigLoader, [
'cache' => WEBROOT . '/core/TemplateCache',
'auto_reload' => true
]);
}
protected function getTemplateName(): string {
return $this->templateName;
}
protected function loadParameters() {
}
public function getCode(): string {
parent::getCode();
$this->loadParameters();
return $this->renderTemplate($this->templateName, $this->parameters);
}
public function renderTemplate(string $name, array $params = []): string {
try {
$params["user"] = [
"lang" => $this->user->getLanguage()->getShortCode(),
"loggedIn" => $this->user->isLoggedIn(),
];
$settings = $this->user->getConfiguration()->getSettings();
$params["site"] = [
"name" => $settings->getSiteName(),
"baseUrl" => $settings->getBaseUrl(),
"recaptcha" => [
"key" => $settings->isRecaptchaEnabled() ? $settings->getRecaptchaSiteKey() : null,
"enabled" => $settings->isRecaptchaEnabled(),
],
"csp" => [
"nonce" => $this->getCSPNonce(),
"enabled" => $this->isCSPEnabled()
]
];
return $this->twigEnvironment->render($name, $params);
} catch (LoaderError | RuntimeError | SyntaxError $e) {
return "<b>Error rendering twig template: " . htmlspecialchars($e->getMessage()) . "</b>";
}
}
}

1
core/External/.gitignore vendored Normal file

@ -0,0 +1 @@
vendor/

@ -1,4 +1,5 @@
<?php <?php
/** /**
* PHPMailer Exception class. * PHPMailer Exception class.
* PHP Version 5.5. * PHP Version 5.5.
@ -9,7 +10,7 @@
* @author Jim Jagielski (jimjag) <jimjag@gmail.com> * @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net> * @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder) * @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2017 Marcus Bointon * @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski * @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost * @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@ -34,6 +35,6 @@ class Exception extends \Exception
*/ */
public function errorMessage() public function errorMessage()
{ {
return '<strong>' . htmlspecialchars($this->getMessage()) . "</strong><br />\n"; return '<strong>' . htmlspecialchars($this->getMessage(), ENT_COMPAT | ENT_HTML401) . "</strong><br />\n";
} }
} }

@ -1,4 +1,5 @@
<?php <?php
/** /**
* PHPMailer - PHP email creation and transport class. * PHPMailer - PHP email creation and transport class.
* PHP Version 5.5. * PHP Version 5.5.
@ -9,7 +10,7 @@
* @author Jim Jagielski (jimjag) <jimjag@gmail.com> * @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net> * @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder) * @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2015 Marcus Bointon * @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski * @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost * @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@ -122,7 +123,7 @@ class OAuth
*/ */
public function getOauth64() public function getOauth64()
{ {
// Get a new token if it's not available or has expired //Get a new token if it's not available or has expired
if (null === $this->oauthToken || $this->oauthToken->hasExpired()) { if (null === $this->oauthToken || $this->oauthToken->hasExpired()) {
$this->oauthToken = $this->getToken(); $this->oauthToken = $this->getToken();
} }

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
<?php <?php
/** /**
* PHPMailer POP-Before-SMTP Authentication Class. * PHPMailer POP-Before-SMTP Authentication Class.
* PHP Version 5.5. * PHP Version 5.5.
@ -9,7 +10,7 @@
* @author Jim Jagielski (jimjag) <jimjag@gmail.com> * @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net> * @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder) * @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2019 Marcus Bointon * @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski * @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost * @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@ -45,7 +46,7 @@ class POP3
* *
* @var string * @var string
*/ */
const VERSION = '6.1.4'; const VERSION = '6.5.1';
/** /**
* Default POP3 port number. * Default POP3 port number.
@ -62,12 +63,16 @@ class POP3
const DEFAULT_TIMEOUT = 30; const DEFAULT_TIMEOUT = 30;
/** /**
* Debug display level. * POP3 class debug output mode.
* Options: 0 = no, 1+ = yes. * Debug output level.
* Options:
* @see POP3::DEBUG_OFF: No output
* @see POP3::DEBUG_SERVER: Server messages, connection/server errors
* @see POP3::DEBUG_CLIENT: Client and Server messages, connection/server errors
* *
* @var int * @var int
*/ */
public $do_debug = 0; public $do_debug = self::DEBUG_OFF;
/** /**
* POP3 mail server hostname. * POP3 mail server hostname.
@ -130,6 +135,28 @@ class POP3
*/ */
const LE = "\r\n"; const LE = "\r\n";
/**
* Debug level for no output.
*
* @var int
*/
const DEBUG_OFF = 0;
/**
* Debug level to show server -> client messages
* also shows clients connection errors or errors from server
*
* @var int
*/
const DEBUG_SERVER = 1;
/**
* Debug level to show client -> server and server -> client messages.
*
* @var int
*/
const DEBUG_CLIENT = 2;
/** /**
* Simple static wrapper for all-in-one POP before SMTP. * Simple static wrapper for all-in-one POP before SMTP.
* *
@ -172,13 +199,13 @@ class POP3
public function authorise($host, $port = false, $timeout = false, $username = '', $password = '', $debug_level = 0) public function authorise($host, $port = false, $timeout = false, $username = '', $password = '', $debug_level = 0)
{ {
$this->host = $host; $this->host = $host;
// If no port value provided, use default //If no port value provided, use default
if (false === $port) { if (false === $port) {
$this->port = static::DEFAULT_PORT; $this->port = static::DEFAULT_PORT;
} else { } else {
$this->port = (int) $port; $this->port = (int) $port;
} }
// If no timeout value provided, use default //If no timeout value provided, use default
if (false === $timeout) { if (false === $timeout) {
$this->tval = static::DEFAULT_TIMEOUT; $this->tval = static::DEFAULT_TIMEOUT;
} else { } else {
@ -187,9 +214,9 @@ class POP3
$this->do_debug = $debug_level; $this->do_debug = $debug_level;
$this->username = $username; $this->username = $username;
$this->password = $password; $this->password = $password;
// Reset the error log //Reset the error log
$this->errors = []; $this->errors = [];
// connect //Connect
$result = $this->connect($this->host, $this->port, $this->tval); $result = $this->connect($this->host, $this->port, $this->tval);
if ($result) { if ($result) {
$login_result = $this->login($this->username, $this->password); $login_result = $this->login($this->username, $this->password);
@ -199,7 +226,7 @@ class POP3
return true; return true;
} }
} }
// We need to disconnect regardless of whether the login succeeded //We need to disconnect regardless of whether the login succeeded
$this->disconnect(); $this->disconnect();
return false; return false;
@ -216,7 +243,7 @@ class POP3
*/ */
public function connect($host, $port = false, $tval = 30) public function connect($host, $port = false, $tval = 30)
{ {
// Are we already connected? //Are we already connected?
if ($this->connected) { if ($this->connected) {
return true; return true;
} }
@ -229,20 +256,22 @@ class POP3
$port = static::DEFAULT_PORT; $port = static::DEFAULT_PORT;
} }
// connect to the POP3 server //Connect to the POP3 server
$errno = 0;
$errstr = '';
$this->pop_conn = fsockopen( $this->pop_conn = fsockopen(
$host, // POP3 Host $host, //POP3 Host
$port, // Port # $port, //Port #
$errno, // Error Number $errno, //Error Number
$errstr, // Error Message $errstr, //Error Message
$tval $tval
); // Timeout (seconds) ); //Timeout (seconds)
// Restore the error handler //Restore the error handler
restore_error_handler(); restore_error_handler();
// Did we connect? //Did we connect?
if (false === $this->pop_conn) { if (false === $this->pop_conn) {
// It would appear not... //It would appear not...
$this->setError( $this->setError(
"Failed to connect to server $host on port $port. errno: $errno; errstr: $errstr" "Failed to connect to server $host on port $port. errno: $errno; errstr: $errstr"
); );
@ -250,14 +279,14 @@ class POP3
return false; return false;
} }
// Increase the stream time-out //Increase the stream time-out
stream_set_timeout($this->pop_conn, $tval, 0); stream_set_timeout($this->pop_conn, $tval, 0);
// Get the POP3 server response //Get the POP3 server response
$pop3_response = $this->getResponse(); $pop3_response = $this->getResponse();
// Check for the +OK //Check for the +OK
if ($this->checkResponse($pop3_response)) { if ($this->checkResponse($pop3_response)) {
// The connection is established and the POP3 server is talking //The connection is established and the POP3 server is talking
$this->connected = true; $this->connected = true;
return true; return true;
@ -279,6 +308,7 @@ class POP3
{ {
if (!$this->connected) { if (!$this->connected) {
$this->setError('Not connected to POP3 server'); $this->setError('Not connected to POP3 server');
return false;
} }
if (empty($username)) { if (empty($username)) {
$username = $this->username; $username = $this->username;
@ -287,11 +317,11 @@ class POP3
$password = $this->password; $password = $this->password;
} }
// Send the Username //Send the Username
$this->sendString("USER $username" . static::LE); $this->sendString("USER $username" . static::LE);
$pop3_response = $this->getResponse(); $pop3_response = $this->getResponse();
if ($this->checkResponse($pop3_response)) { if ($this->checkResponse($pop3_response)) {
// Send the Password //Send the Password
$this->sendString("PASS $password" . static::LE); $this->sendString("PASS $password" . static::LE);
$pop3_response = $this->getResponse(); $pop3_response = $this->getResponse();
if ($this->checkResponse($pop3_response)) { if ($this->checkResponse($pop3_response)) {
@ -308,6 +338,15 @@ class POP3
public function disconnect() public function disconnect()
{ {
$this->sendString('QUIT'); $this->sendString('QUIT');
// RFC 1939 shows POP3 server sending a +OK response to the QUIT command.
// Try to get it. Ignore any failures here.
try {
$this->getResponse();
} catch (Exception $e) {
//Do nothing
}
//The QUIT command may cause the daemon to exit, which will kill our connection //The QUIT command may cause the daemon to exit, which will kill our connection
//So ignore errors here //So ignore errors here
try { try {
@ -315,6 +354,10 @@ class POP3
} catch (Exception $e) { } catch (Exception $e) {
//Do nothing //Do nothing
} }
// Clean up attributes.
$this->connected = false;
$this->pop_conn = false;
} }
/** /**
@ -327,7 +370,7 @@ class POP3
protected function getResponse($size = 128) protected function getResponse($size = 128)
{ {
$response = fgets($this->pop_conn, $size); $response = fgets($this->pop_conn, $size);
if ($this->do_debug >= 1) { if ($this->do_debug >= self::DEBUG_SERVER) {
echo 'Server -> Client: ', $response; echo 'Server -> Client: ', $response;
} }
@ -344,7 +387,7 @@ class POP3
protected function sendString($string) protected function sendString($string)
{ {
if ($this->pop_conn) { if ($this->pop_conn) {
if ($this->do_debug >= 2) { //Show client messages when debug >= 2 if ($this->do_debug >= self::DEBUG_CLIENT) { //Show client messages when debug >= 2
echo 'Client -> Server: ', $string; echo 'Client -> Server: ', $string;
} }
@ -382,7 +425,7 @@ class POP3
protected function setError($error) protected function setError($error)
{ {
$this->errors[] = $error; $this->errors[] = $error;
if ($this->do_debug >= 1) { if ($this->do_debug >= self::DEBUG_SERVER) {
echo '<pre>'; echo '<pre>';
foreach ($this->errors as $e) { foreach ($this->errors as $e) {
print_r($e); print_r($e);

@ -1,4 +1,5 @@
<?php <?php
/** /**
* PHPMailer RFC821 SMTP email transport class. * PHPMailer RFC821 SMTP email transport class.
* PHP Version 5.5. * PHP Version 5.5.
@ -9,7 +10,7 @@
* @author Jim Jagielski (jimjag) <jimjag@gmail.com> * @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net> * @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder) * @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2019 Marcus Bointon * @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski * @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost * @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@ -34,7 +35,7 @@ class SMTP
* *
* @var string * @var string
*/ */
const VERSION = '6.1.4'; const VERSION = '6.5.1';
/** /**
* SMTP line break constant. * SMTP line break constant.
@ -185,6 +186,7 @@ class SMTP
'Amazon_SES' => '/[\d]{3} Ok (.*)/', 'Amazon_SES' => '/[\d]{3} Ok (.*)/',
'SendGrid' => '/[\d]{3} Ok: queued as (.*)/', 'SendGrid' => '/[\d]{3} Ok: queued as (.*)/',
'CampaignMonitor' => '/[\d]{3} 2.0.0 OK:([a-zA-Z\d]{48})/', 'CampaignMonitor' => '/[\d]{3} 2.0.0 OK:([a-zA-Z\d]{48})/',
'Haraka' => '/[\d]{3} Message Queued \((.*)\)/',
]; ];
/** /**
@ -311,17 +313,11 @@ class SMTP
*/ */
public function connect($host, $port = null, $timeout = 30, $options = []) public function connect($host, $port = null, $timeout = 30, $options = [])
{ {
static $streamok; //Clear errors to avoid confusion
//This is enabled by default since 5.0.0 but some providers disable it
//Check this once and cache the result
if (null === $streamok) {
$streamok = function_exists('stream_socket_client');
}
// Clear errors to avoid confusion
$this->setError(''); $this->setError('');
// Make sure we are __not__ connected //Make sure we are __not__ connected
if ($this->connected()) { if ($this->connected()) {
// Already connected, generate error //Already connected, generate error
$this->setError('Already connected to a server'); $this->setError('Already connected to a server');
return false; return false;
@ -329,18 +325,66 @@ class SMTP
if (empty($port)) { if (empty($port)) {
$port = self::DEFAULT_PORT; $port = self::DEFAULT_PORT;
} }
// Connect to the SMTP server //Connect to the SMTP server
$this->edebug( $this->edebug(
"Connection: opening to $host:$port, timeout=$timeout, options=" . "Connection: opening to $host:$port, timeout=$timeout, options=" .
(count($options) > 0 ? var_export($options, true) : 'array()'), (count($options) > 0 ? var_export($options, true) : 'array()'),
self::DEBUG_CONNECTION self::DEBUG_CONNECTION
); );
$this->smtp_conn = $this->getSMTPConnection($host, $port, $timeout, $options);
if ($this->smtp_conn === false) {
//Error info already set inside `getSMTPConnection()`
return false;
}
$this->edebug('Connection: opened', self::DEBUG_CONNECTION);
//Get any announcement
$this->last_reply = $this->get_lines();
$this->edebug('SERVER -> CLIENT: ' . $this->last_reply, self::DEBUG_SERVER);
$responseCode = (int)substr($this->last_reply, 0, 3);
if ($responseCode === 220) {
return true;
}
//Anything other than a 220 response means something went wrong
//RFC 5321 says the server will wait for us to send a QUIT in response to a 554 error
//https://tools.ietf.org/html/rfc5321#section-3.1
if ($responseCode === 554) {
$this->quit();
}
//This will handle 421 responses which may not wait for a QUIT (e.g. if the server is being shut down)
$this->edebug('Connection: closing due to error', self::DEBUG_CONNECTION);
$this->close();
return false;
}
/**
* Create connection to the SMTP server.
*
* @param string $host SMTP server IP or host name
* @param int $port The port number to connect to
* @param int $timeout How long to wait for the connection to open
* @param array $options An array of options for stream_context_create()
*
* @return false|resource
*/
protected function getSMTPConnection($host, $port = null, $timeout = 30, $options = [])
{
static $streamok;
//This is enabled by default since 5.0.0 but some providers disable it
//Check this once and cache the result
if (null === $streamok) {
$streamok = function_exists('stream_socket_client');
}
$errno = 0; $errno = 0;
$errstr = ''; $errstr = '';
if ($streamok) { if ($streamok) {
$socket_context = stream_context_create($options); $socket_context = stream_context_create($options);
set_error_handler([$this, 'errorHandler']); set_error_handler([$this, 'errorHandler']);
$this->smtp_conn = stream_socket_client( $connection = stream_socket_client(
$host . ':' . $port, $host . ':' . $port,
$errno, $errno,
$errstr, $errstr,
@ -348,7 +392,6 @@ class SMTP
STREAM_CLIENT_CONNECT, STREAM_CLIENT_CONNECT,
$socket_context $socket_context
); );
restore_error_handler();
} else { } else {
//Fall back to fsockopen which should work in more places, but is missing some features //Fall back to fsockopen which should work in more places, but is missing some features
$this->edebug( $this->edebug(
@ -356,17 +399,18 @@ class SMTP
self::DEBUG_CONNECTION self::DEBUG_CONNECTION
); );
set_error_handler([$this, 'errorHandler']); set_error_handler([$this, 'errorHandler']);
$this->smtp_conn = fsockopen( $connection = fsockopen(
$host, $host,
$port, $port,
$errno, $errno,
$errstr, $errstr,
$timeout $timeout
); );
restore_error_handler();
} }
// Verify we connected properly restore_error_handler();
if (!is_resource($this->smtp_conn)) {
//Verify we connected properly
if (!is_resource($connection)) {
$this->setError( $this->setError(
'Failed to connect to server', 'Failed to connect to server',
'', '',
@ -381,22 +425,19 @@ class SMTP
return false; return false;
} }
$this->edebug('Connection: opened', self::DEBUG_CONNECTION);
// SMTP server can take longer to respond, give longer timeout for first read //SMTP server can take longer to respond, give longer timeout for first read
// Windows does not have support for this timeout function //Windows does not have support for this timeout function
if (strpos(PHP_OS, 'WIN') !== 0) { if (strpos(PHP_OS, 'WIN') !== 0) {
$max = (int) ini_get('max_execution_time'); $max = (int)ini_get('max_execution_time');
// Don't bother if unlimited //Don't bother if unlimited, or if set_time_limit is disabled
if (0 !== $max && $timeout > $max) { if (0 !== $max && $timeout > $max && strpos(ini_get('disable_functions'), 'set_time_limit') === false) {
@set_time_limit($timeout); @set_time_limit($timeout);
} }
stream_set_timeout($this->smtp_conn, $timeout, 0); stream_set_timeout($connection, $timeout, 0);
} }
// Get any announcement
$announce = $this->get_lines();
$this->edebug('SERVER -> CLIENT: ' . $announce, self::DEBUG_SERVER);
return true; return $connection;
} }
/** /**
@ -420,7 +461,7 @@ class SMTP
$crypto_method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; $crypto_method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT;
} }
// Begin encrypted connection //Begin encrypted connection
set_error_handler([$this, 'errorHandler']); set_error_handler([$this, 'errorHandler']);
$crypto_ok = stream_socket_enable_crypto( $crypto_ok = stream_socket_enable_crypto(
$this->smtp_conn, $this->smtp_conn,
@ -458,11 +499,11 @@ class SMTP
} }
if (array_key_exists('EHLO', $this->server_caps)) { if (array_key_exists('EHLO', $this->server_caps)) {
// SMTP extensions are available; try to find a proper authentication method //SMTP extensions are available; try to find a proper authentication method
if (!array_key_exists('AUTH', $this->server_caps)) { if (!array_key_exists('AUTH', $this->server_caps)) {
$this->setError('Authentication is not allowed at this stage'); $this->setError('Authentication is not allowed at this stage');
// 'at this stage' means that auth may be allowed after the stage changes //'at this stage' means that auth may be allowed after the stage changes
// e.g. after STARTTLS //e.g. after STARTTLS
return false; return false;
} }
@ -506,22 +547,25 @@ class SMTP
} }
switch ($authtype) { switch ($authtype) {
case 'PLAIN': case 'PLAIN':
// Start authentication //Start authentication
if (!$this->sendCommand('AUTH', 'AUTH PLAIN', 334)) { if (!$this->sendCommand('AUTH', 'AUTH PLAIN', 334)) {
return false; return false;
} }
// Send encoded username and password //Send encoded username and password
if (!$this->sendCommand( if (
'User & Password', //Format from https://tools.ietf.org/html/rfc4616#section-2
base64_encode("\0" . $username . "\0" . $password), //We skip the first field (it's forgery), so the string starts with a null byte
235 !$this->sendCommand(
) 'User & Password',
base64_encode("\0" . $username . "\0" . $password),
235
)
) { ) {
return false; return false;
} }
break; break;
case 'LOGIN': case 'LOGIN':
// Start authentication //Start authentication
if (!$this->sendCommand('AUTH', 'AUTH LOGIN', 334)) { if (!$this->sendCommand('AUTH', 'AUTH LOGIN', 334)) {
return false; return false;
} }
@ -533,17 +577,17 @@ class SMTP
} }
break; break;
case 'CRAM-MD5': case 'CRAM-MD5':
// Start authentication //Start authentication
if (!$this->sendCommand('AUTH CRAM-MD5', 'AUTH CRAM-MD5', 334)) { if (!$this->sendCommand('AUTH CRAM-MD5', 'AUTH CRAM-MD5', 334)) {
return false; return false;
} }
// Get the challenge //Get the challenge
$challenge = base64_decode(substr($this->last_reply, 4)); $challenge = base64_decode(substr($this->last_reply, 4));
// Build the response //Build the response
$response = $username . ' ' . $this->hmac($challenge, $password); $response = $username . ' ' . $this->hmac($challenge, $password);
// send encoded credentials //send encoded credentials
return $this->sendCommand('Username', base64_encode($response), 235); return $this->sendCommand('Username', base64_encode($response), 235);
case 'XOAUTH2': case 'XOAUTH2':
//The OAuth instance must be set up prior to requesting auth. //The OAuth instance must be set up prior to requesting auth.
@ -552,7 +596,7 @@ class SMTP
} }
$oauth = $OAuth->getOauth64(); $oauth = $OAuth->getOauth64();
// Start authentication //Start authentication
if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) { if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) {
return false; return false;
} }
@ -582,15 +626,15 @@ class SMTP
return hash_hmac('md5', $data, $key); return hash_hmac('md5', $data, $key);
} }
// The following borrowed from //The following borrowed from
// http://php.net/manual/en/function.mhash.php#27225 //http://php.net/manual/en/function.mhash.php#27225
// RFC 2104 HMAC implementation for php. //RFC 2104 HMAC implementation for php.
// Creates an md5 HMAC. //Creates an md5 HMAC.
// Eliminates the need to install mhash to compute a HMAC //Eliminates the need to install mhash to compute a HMAC
// by Lance Rushing //by Lance Rushing
$bytelen = 64; // byte length for md5 $bytelen = 64; //byte length for md5
if (strlen($key) > $bytelen) { if (strlen($key) > $bytelen) {
$key = pack('H*', md5($key)); $key = pack('H*', md5($key));
} }
@ -613,7 +657,7 @@ class SMTP
if (is_resource($this->smtp_conn)) { if (is_resource($this->smtp_conn)) {
$sock_status = stream_get_meta_data($this->smtp_conn); $sock_status = stream_get_meta_data($this->smtp_conn);
if ($sock_status['eof']) { if ($sock_status['eof']) {
// The socket is valid but we are not connected //The socket is valid but we are not connected
$this->edebug( $this->edebug(
'SMTP NOTICE: EOF caught while checking if connected', 'SMTP NOTICE: EOF caught while checking if connected',
self::DEBUG_CLIENT self::DEBUG_CLIENT
@ -623,7 +667,7 @@ class SMTP
return false; return false;
} }
return true; // everything looks good return true; //everything looks good
} }
return false; return false;
@ -641,7 +685,7 @@ class SMTP
$this->server_caps = null; $this->server_caps = null;
$this->helo_rply = null; $this->helo_rply = null;
if (is_resource($this->smtp_conn)) { if (is_resource($this->smtp_conn)) {
// close the connection and cleanup //Close the connection and cleanup
fclose($this->smtp_conn); fclose($this->smtp_conn);
$this->smtp_conn = null; //Makes for cleaner serialization $this->smtp_conn = null; //Makes for cleaner serialization
$this->edebug('Connection: closed', self::DEBUG_CONNECTION); $this->edebug('Connection: closed', self::DEBUG_CONNECTION);
@ -651,7 +695,7 @@ class SMTP
/** /**
* Send an SMTP DATA command. * Send an SMTP DATA command.
* Issues a data command and sends the msg_data to the server, * Issues a data command and sends the msg_data to the server,
* finializing the mail transaction. $msg_data is the message * finalizing the mail transaction. $msg_data is the message
* that is to be send with the headers. Each header needs to be * that is to be send with the headers. Each header needs to be
* on a single line followed by a <CRLF> with the message headers * on a single line followed by a <CRLF> with the message headers
* and the message body being separated by an additional <CRLF>. * and the message body being separated by an additional <CRLF>.
@ -676,7 +720,7 @@ class SMTP
* NOTE: this does not count towards line-length limit. * NOTE: this does not count towards line-length limit.
*/ */
// Normalize line breaks before exploding //Normalize line breaks before exploding
$lines = explode("\n", str_replace(["\r\n", "\r"], "\n", $msg_data)); $lines = explode("\n", str_replace(["\r\n", "\r"], "\n", $msg_data));
/* To distinguish between a complete RFC822 message and a plain message body, we check if the first field /* To distinguish between a complete RFC822 message and a plain message body, we check if the first field
@ -722,7 +766,8 @@ class SMTP
//Send the lines to the server //Send the lines to the server
foreach ($lines_out as $line_out) { foreach ($lines_out as $line_out) {
//RFC2821 section 4.5.2 //Dot-stuffing as per RFC5321 section 4.5.2
//https://tools.ietf.org/html/rfc5321#section-4.5.2
if (!empty($line_out) && $line_out[0] === '.') { if (!empty($line_out) && $line_out[0] === '.') {
$line_out = '.' . $line_out; $line_out = '.' . $line_out;
} }
@ -756,7 +801,16 @@ class SMTP
public function hello($host = '') public function hello($host = '')
{ {
//Try extended hello first (RFC 2821) //Try extended hello first (RFC 2821)
return $this->sendHello('EHLO', $host) or $this->sendHello('HELO', $host); if ($this->sendHello('EHLO', $host)) {
return true;
}
//Some servers shut down the SMTP service here (RFC 5321)
if (substr($this->helo_rply, 0, 3) == '421') {
return false;
}
return $this->sendHello('HELO', $host);
} }
/** /**
@ -946,12 +1000,12 @@ class SMTP
$this->client_send($commandstring . static::LE, $command); $this->client_send($commandstring . static::LE, $command);
$this->last_reply = $this->get_lines(); $this->last_reply = $this->get_lines();
// Fetch SMTP code and possible error code explanation //Fetch SMTP code and possible error code explanation
$matches = []; $matches = [];
if (preg_match('/^([\d]{3})[ -](?:([\d]\\.[\d]\\.[\d]{1,2}) )?/', $this->last_reply, $matches)) { if (preg_match('/^([\d]{3})[ -](?:([\d]\\.[\d]\\.[\d]{1,2}) )?/', $this->last_reply, $matches)) {
$code = (int) $matches[1]; $code = (int) $matches[1];
$code_ex = (count($matches) > 2 ? $matches[2] : null); $code_ex = (count($matches) > 2 ? $matches[2] : null);
// Cut off error code from each response line //Cut off error code from each response line
$detail = preg_replace( $detail = preg_replace(
"/{$code}[ -]" . "/{$code}[ -]" .
($code_ex ? str_replace('.', '\\.', $code_ex) . ' ' : '') . '/m', ($code_ex ? str_replace('.', '\\.', $code_ex) . ' ' : '') . '/m',
@ -959,7 +1013,7 @@ class SMTP
$this->last_reply $this->last_reply
); );
} else { } else {
// Fall back to simple parsing if regex fails //Fall back to simple parsing if regex fails
$code = (int) substr($this->last_reply, 0, 3); $code = (int) substr($this->last_reply, 0, 3);
$code_ex = null; $code_ex = null;
$detail = substr($this->last_reply, 4); $detail = substr($this->last_reply, 4);
@ -1058,8 +1112,10 @@ class SMTP
{ {
//If SMTP transcripts are left enabled, or debug output is posted online //If SMTP transcripts are left enabled, or debug output is posted online
//it can leak credentials, so hide credentials in all but lowest level //it can leak credentials, so hide credentials in all but lowest level
if (self::DEBUG_LOWLEVEL > $this->do_debug && if (
in_array($command, ['User & Password', 'Username', 'Password'], true)) { self::DEBUG_LOWLEVEL > $this->do_debug &&
in_array($command, ['User & Password', 'Username', 'Password'], true)
) {
$this->edebug('CLIENT -> SERVER: [credentials hidden]', self::DEBUG_CLIENT); $this->edebug('CLIENT -> SERVER: [credentials hidden]', self::DEBUG_CLIENT);
} else { } else {
$this->edebug('CLIENT -> SERVER: ' . $data, self::DEBUG_CLIENT); $this->edebug('CLIENT -> SERVER: ' . $data, self::DEBUG_CLIENT);
@ -1113,7 +1169,7 @@ class SMTP
if (!$this->server_caps) { if (!$this->server_caps) {
$this->setError('No HELO/EHLO was sent'); $this->setError('No HELO/EHLO was sent');
return; return null;
} }
if (!array_key_exists($name, $this->server_caps)) { if (!array_key_exists($name, $this->server_caps)) {
@ -1125,7 +1181,7 @@ class SMTP
} }
$this->setError('HELO handshake was used; No information about server extensions available'); $this->setError('HELO handshake was used; No information about server extensions available');
return; return null;
} }
return $this->server_caps[$name]; return $this->server_caps[$name];
@ -1152,7 +1208,7 @@ class SMTP
*/ */
protected function get_lines() protected function get_lines()
{ {
// If the connection is bad, give up straight away //If the connection is bad, give up straight away
if (!is_resource($this->smtp_conn)) { if (!is_resource($this->smtp_conn)) {
return ''; return '';
} }
@ -1166,33 +1222,61 @@ class SMTP
$selW = null; $selW = null;
while (is_resource($this->smtp_conn) && !feof($this->smtp_conn)) { while (is_resource($this->smtp_conn) && !feof($this->smtp_conn)) {
//Must pass vars in here as params are by reference //Must pass vars in here as params are by reference
if (!stream_select($selR, $selW, $selW, $this->Timelimit)) { //solution for signals inspired by https://github.com/symfony/symfony/pull/6540
set_error_handler([$this, 'errorHandler']);
$n = stream_select($selR, $selW, $selW, $this->Timelimit);
restore_error_handler();
if ($n === false) {
$message = $this->getError()['detail'];
$this->edebug( $this->edebug(
'SMTP -> get_lines(): timed-out (' . $this->Timeout . ' sec)', 'SMTP -> get_lines(): select failed (' . $message . ')',
self::DEBUG_LOWLEVEL
);
//stream_select returns false when the `select` system call is interrupted
//by an incoming signal, try the select again
if (stripos($message, 'interrupted system call') !== false) {
$this->edebug(
'SMTP -> get_lines(): retrying stream_select',
self::DEBUG_LOWLEVEL
);
$this->setError('');
continue;
}
break;
}
if (!$n) {
$this->edebug(
'SMTP -> get_lines(): select timed-out in (' . $this->Timelimit . ' sec)',
self::DEBUG_LOWLEVEL self::DEBUG_LOWLEVEL
); );
break; break;
} }
//Deliberate noise suppression - errors are handled afterwards //Deliberate noise suppression - errors are handled afterwards
$str = @fgets($this->smtp_conn, self::MAX_REPLY_LENGTH); $str = @fgets($this->smtp_conn, self::MAX_REPLY_LENGTH);
$this->edebug('SMTP INBOUND: "' . trim($str) . '"', self::DEBUG_LOWLEVEL); $this->edebug('SMTP INBOUND: "' . trim($str) . '"', self::DEBUG_LOWLEVEL);
$data .= $str; $data .= $str;
// If response is only 3 chars (not valid, but RFC5321 S4.2 says it must be handled), //If response is only 3 chars (not valid, but RFC5321 S4.2 says it must be handled),
// or 4th character is a space or a line break char, we are done reading, break the loop. //or 4th character is a space or a line break char, we are done reading, break the loop.
// String array access is a significant micro-optimisation over strlen //String array access is a significant micro-optimisation over strlen
if (!isset($str[3]) || $str[3] === ' ' || $str[3] === "\r" || $str[3] === "\n") { if (!isset($str[3]) || $str[3] === ' ' || $str[3] === "\r" || $str[3] === "\n") {
break; break;
} }
// Timed-out? Log and break //Timed-out? Log and break
$info = stream_get_meta_data($this->smtp_conn); $info = stream_get_meta_data($this->smtp_conn);
if ($info['timed_out']) { if ($info['timed_out']) {
$this->edebug( $this->edebug(
'SMTP -> get_lines(): timed-out (' . $this->Timeout . ' sec)', 'SMTP -> get_lines(): stream timed-out (' . $this->Timeout . ' sec)',
self::DEBUG_LOWLEVEL self::DEBUG_LOWLEVEL
); );
break; break;
} }
// Now check if reads took too long //Now check if reads took too long
if ($endtime && time() > $endtime) { if ($endtime && time() > $endtime) {
$this->edebug( $this->edebug(
'SMTP -> get_lines(): timelimit reached (' . 'SMTP -> get_lines(): timelimit reached (' .
@ -1344,6 +1428,7 @@ class SMTP
} else { } else {
$this->last_smtp_transaction_id = false; $this->last_smtp_transaction_id = false;
foreach ($this->smtp_transaction_id_patterns as $smtp_transaction_id_pattern) { foreach ($this->smtp_transaction_id_patterns as $smtp_transaction_id_pattern) {
$matches = [];
if (preg_match($smtp_transaction_id_pattern, $reply, $matches)) { if (preg_match($smtp_transaction_id_pattern, $reply, $matches)) {
$this->last_smtp_transaction_id = trim($matches[1]); $this->last_smtp_transaction_id = trim($matches[1]);
break; break;

@ -26,16 +26,16 @@ namespace External\ZipStream {
private $content = ''; private $content = '';
private $fileHandle = false; private $fileHandle = false;
private $lastModificationTimestamp; private $lastModificationTimestamp;
private $crc32 = null; protected $fileSize = 0;
private $fileSize = 0; protected $compressedSize = 0;
private $compressedSize = 0;
private $offset = 0; private $offset = 0;
private $bitField = 0; private $bitField = 0;
private $useCompression = true; protected $useCompression = true;
private $deflateState = null; private $deflateState = null;
//check for duplications //currently not used //check for duplications //currently not used
private $sha256; protected $crc32 = null;
protected $sha256;
public const BIT_NO_SIZE_IN_HEADER = 0b0000000000001000; public const BIT_NO_SIZE_IN_HEADER = 0b0000000000001000;
public const BIT_UTF8_NAMES = 0b0000100000000000; public const BIT_UTF8_NAMES = 0b0000100000000000;
@ -45,12 +45,17 @@ namespace External\ZipStream {
$this->lastModificationTimestamp = time(); $this->lastModificationTimestamp = time();
$this->crc32 = hash('crc32b', '', true); $this->crc32 = hash('crc32b', '', true);
$this->compressedSize = 0; $this->compressedSize = 0;
$this->fileSize = 0;
$this->bitField = 0; $this->bitField = 0;
$this->bitField |= self::BIT_NO_SIZE_IN_HEADER; $this->bitField |= self::BIT_NO_SIZE_IN_HEADER;
$this->bitField |= self::BIT_UTF8_NAMES; $this->bitField |= self::BIT_UTF8_NAMES;
$this->deflateState = deflate_init(ZLIB_ENCODING_RAW, ['level' => 9]); $this->deflateState = deflate_init(ZLIB_ENCODING_RAW);
}
public function disableCompression() {
$this->useCompression = false;
} }
public function setContent($content) { public function setContent($content) {
@ -68,13 +73,6 @@ namespace External\ZipStream {
$this->fileHandle = fopen($filename, 'rb'); $this->fileHandle = fopen($filename, 'rb');
} }
public function loadFromBuffer($buf) {
$this->crc32 = hash('crc32b', $buf, true);
$this->sha256 = hash('sha256', $buf);
$this->fileSize = strlen($buf);
$this->content = $buf;
}
public function name() { public function name() {
return $this->name; return $this->name;
} }
@ -101,14 +99,14 @@ namespace External\ZipStream {
($day); ($day);
} }
public function readLocalFileHeader() { public function readLocalFileHeader(bool $zip64 = false) {
if (!$this->useCompression) { if (!$this->useCompression) {
$this->compressedSize = $this->fileSize; $this->compressedSize = $this->fileSize;
} }
$header = ""; $header = "";
$header .= "\x50\x4b\x03\x04"; $header .= "\x50\x4b\x03\x04";
$header .= "\x14\x00"; //version 2.0 and MS-DOS compatible $header .= $zip64 ? "\x2d\x00" : "\x14\x00"; //version 2.0 and MS-DOS compatible
$header .= pack("v", $this->bitField); //general purpose bit flag $header .= pack("v", $this->bitField); //general purpose bit flag
if ($this->useCompression) { if ($this->useCompression) {
$header .= "\x08\x00"; //compression Method - deflate $header .= "\x08\x00"; //compression Method - deflate
@ -117,28 +115,59 @@ namespace External\ZipStream {
} }
$header .= pack("v", $this->unixTimeToDosTime($this->lastModificationTimestamp)); //dos time $header .= pack("v", $this->unixTimeToDosTime($this->lastModificationTimestamp)); //dos time
$header .= pack("v", $this->unixTimeToDosDate($this->lastModificationTimestamp)); //dos date $header .= pack("v", $this->unixTimeToDosDate($this->lastModificationTimestamp)); //dos date
if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
$header .= pack("V", 0); //crc32 if ($zip64) {
$header .= pack("V", 0); //compressed Size if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
$header .= pack("V", 0); //uncompressed Size $header .= pack("V", 0); //crc32
} else {
$header .= strrev($this->crc32);
}
$header .= "\xFF\xFF\xFF\xFF"; //compressed Size
$header .= "\xFF\xFF\xFF\xFF"; //uncompressed Size
} else { } else {
$header .= strrev($this->crc32); if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
$header .= pack("V", $this->compressedSize); //compressed Size $header .= pack("V", 0); //crc32
$header .= pack("V", $this->fileSize); //uncompressed Size $header .= pack("V", 0); //compressed Size
$header .= pack("V", 0); //uncompressed Size
} else {
$header .= strrev($this->crc32);
$header .= pack("V", $this->compressedSize); //compressed Size
$header .= pack("V", $this->fileSize); //uncompressed Size
}
} }
$header .= pack("v", strlen($this->name)); //filename $header .= pack("v", strlen($this->name)); //filename
$header .= "\x00\x00"; //extra field length if ($zip64) {
$header .= $this->name; $header .= pack("v", 16+4); //extra field length (signatures + data)
$header .= $this->name;
$header .= pack("v", 0x0001); # Zip64 extended information extra field
$header .= pack("v", 16); // 2 * 8 byte
if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
$header .= pack("P", 0);
$header .= pack("P", 0);
} else {
$header .= pack("P", $this->compressedSize);
$header .= pack("P", $this->fileSize);
}
} else {
$header .= "\x00\x00"; //extra field length
$header .= $this->name;
}
return $header; return $header;
} }
public function readDataDescriptor() { public function readDataDescriptor(bool $zip64 = false) {
if (!$this->useCompression) {
$this->compressedSize = $this->fileSize;
}
$data = ""; $data = "";
$data .= "\x50\x4b\x07\x08"; $data .= "\x50\x4b\x07\x08";
$data .= strrev($this->crc32); $data .= strrev($this->crc32);
$data .= pack("V", $this->compressedSize); //compressed Size $data .= $zip64 ? pack("P", $this->compressedSize) : pack("V", $this->compressedSize); //compressed Size
$data .= pack("V", $this->fileSize); //uncompressed Size $data .= $zip64 ? pack("P", $this->fileSize) : pack("V", $this->fileSize); //uncompressed Size
return $data; return $data;
} }
@ -156,21 +185,28 @@ namespace External\ZipStream {
return $ret; return $ret;
} }
protected function compress($block) {
$ret = null;
if ($this->deflateState !== null) {
if (!empty($block)) {
$ret = deflate_add($this->deflateState, $block, ZLIB_NO_FLUSH);
} else {
$ret = deflate_add($this->deflateState, '', ZLIB_FINISH);
$this->deflateState = null;
}
$this->compressedSize += strlen($ret);
}
return $ret;
}
public function readFileData() { public function readFileData() {
$ret = null; $ret = null;
if ($this->useCompression) { if ($this->useCompression) {
$block = $this->readFileDataImp(); $block = $this->readFileDataImp();
if ($this->deflateState !== null) { $ret = $this->compress($block);
if ($block !== null) {
$ret = deflate_add($this->deflateState, $block, ZLIB_NO_FLUSH);
} else {
$ret = deflate_add($this->deflateState, '', ZLIB_FINISH);
$this->deflateState = null;
}
}
if ($ret !== null) {
$this->compressedSize += strlen($ret);
}
} else { } else {
$ret = $this->readFileDataImp(); $ret = $this->readFileDataImp();
} }
@ -181,27 +217,61 @@ namespace External\ZipStream {
$this->offset = $offset; $this->offset = $offset;
} }
public function readCentralDirectoryHeader() { public function readCentralDirectoryHeader(bool $zip64 = false) {
$maxInt32 = 0xFFFFFFFF;
$extraFields = "";
// Compressed Size
if ($zip64 && $this->compressedSize >= $maxInt32) {
$compressedSize = "\xFF\xFF\xFF\xFF";
$extraFields .= pack("P", $this->compressedSize);
} else {
$compressedSize = pack("V", $this->compressedSize);
}
// Uncompressed Size
if ($zip64 && $this->fileSize >= $maxInt32) {
$fileSize = "\xFF\xFF\xFF\xFF";
$extraFields .= pack("P", $this->fileSize);
} else {
$fileSize = pack("V", $this->fileSize);
}
// Offset
if ($zip64 && $this->offset >= $maxInt32) {
$offset = "\xFF\xFF\xFF\xFF";
$extraFields .= pack("P", $this->offset);
} else {
$offset = pack("V", $this->offset);
}
$header = ""; $header = "";
$header .= "\x50\x4b\x01\x02"; $header .= "\x50\x4b\x01\x02";
$header .= "\x14\x00"; //version 2.0 and MS-DOS compatible $header .= $zip64 ? "\x2d\x00" : "\x14\x00"; //version 2.0 and MS-DOS compatible
$header .= "\x14\x00"; //version 2.0 and MS-DOS compatible $header .= $zip64 ? "\x2d\x00" : "\x14\x00"; //version 2.0 and MS-DOS compatible
$header .= pack("v", $this->bitField); //general purpose bit flag $header .= pack("v", $this->bitField); //general purpose bit flag
$header .= "\x00\x00"; //compression Method - no $header .= $this->useCompression ? "\x08\x00" : "\x00\x00"; //compression Method - no
$header .= pack("v", $this->unixTimeToDosTime($this->lastModificationTimestamp)); //dos time $header .= pack("v", $this->unixTimeToDosTime($this->lastModificationTimestamp)); //dos time
$header .= pack("v", $this->unixTimeToDosDate($this->lastModificationTimestamp)); //dos date $header .= pack("v", $this->unixTimeToDosDate($this->lastModificationTimestamp)); //dos date
$header .= strrev($this->crc32); $header .= strrev($this->crc32);
$header .= pack("V", $this->compressedSize); //compressed Size $header .= $compressedSize; //compressed Size
$header .= pack("V", $this->fileSize); //uncompressed Size $header .= $fileSize; //uncompressed Size
$header .= pack("v", strlen($this->name)); //filename $header .= pack("v", strlen($this->name)); //filename
$header .= "\x00\x00"; //extra field length $header .= (strlen($extraFields) > 0) ? pack('v', strlen($extraFields) + 4) : "\x00\x00"; //extra field length
$header .= "\x00\x00"; //comment length $header .= "\x00\x00"; //comment length
$header .= "\x00\x00"; //disk num start $header .= "\x00\x00"; //disk num start
$header .= "\x00\x00"; //int file attr $header .= "\x00\x00"; //int file attr
$header .= "\x00\x00\x00\x00"; //ext file attr $header .= "\x00\x00\x00\x00"; //ext file attr
$header .= pack("V", $this->offset); //relative offset $header .= $offset; //relative offset
$header .= $this->name; $header .= $this->name;
if (strlen($extraFields) > 0) {
$header .= pack("v", 0x0001); # Zip64 extended information extra field
$header .= pack("v", strlen($extraFields));
$header .= $extraFields;
}
return $header; return $header;
} }

@ -0,0 +1,46 @@
<?php
namespace External\ZipStream {
use HashContext;
use Objects\AesStream;
class FileStream extends File {
private AesStream $stream;
private HashContext $crc32ctx;
private HashContext $sha256ctx;
public function __construct(AesStream $stream, string $name) {
parent::__construct($name);
$this->stream = $stream;
$this->crc32ctx = hash_init('crc32b');
$this->sha256ctx = hash_init('sha256');
}
public function getStream(): AesStream {
return $this->stream;
}
public function finalize() {
$this->crc32 = hash_final($this->crc32ctx, true);
$this->sha256 = hash_final($this->sha256ctx);
return $this->compress(null);
}
public function processChunk($chunk) {
hash_update($this->crc32ctx, $chunk);
hash_update($this->sha256ctx, $chunk);
$this->fileSize += strlen($chunk);
if ($this->useCompression) {
$chunk = $this->compress($chunk);
}
return $chunk;
}
}
}

@ -23,10 +23,12 @@
namespace External\ZipStream { namespace External\ZipStream {
class ZipStream { class ZipStream {
private $writer = null; private $writer = null;
private $files = []; private array $files = [];
private bool $zip64;
public function __construct($writer) { public function __construct($writer, $zip64 = false) {
$this->writer = $writer; $this->writer = $writer;
$this->zip64 = $zip64;
} }
public function saveFile($file) { public function saveFile($file) {
@ -40,32 +42,66 @@ namespace External\ZipStream {
} }
} }
$file->setOffset($this->writer->offset()); $file->setOffset($this->writer->offset());
$this->writer->write($file->readLocalFileHeader()); $this->writer->write($file->readLocalFileHeader($this->zip64));
while (($buffer = $file->readFileData()) !== null) {
$this->writer->write($buffer); if ($file instanceof FileStream) {
$file->getStream()->setOutput(function ($chunk) use ($file) {
$this->writer->write($file->processChunk($chunk));
});
$file->getStream()->start();
$this->writer->write($file->finalize());
} else {
while (($buffer = $file->readFileData()) !== null) {
$this->writer->write($buffer);
}
} }
$this->writer->write($file->readDataDescriptor());
$this->writer->write($file->readDataDescriptor($this->zip64));
$this->files[] = $file; $this->files[] = $file;
$file->closeHandle(); $file->closeHandle();
return true; return true;
} }
// Write end of central directory record
public function close() { public function close() {
$size = 0; $size = 0;
$offset = $this->writer->offset(); $offset = $this->writer->offset();
foreach ($this->files as $file) { foreach ($this->files as $file) {
$size += $this->writer->write($file->readCentralDirectoryHeader()); $size += $this->writer->write($file->readCentralDirectoryHeader($this->zip64));
} }
$data = ""; $data = "";
if ($this->zip64) {
// Size = SizeOfFixedFields + SizeOfVariableData - 12.
$centralDirectorySize = 2*2 + 2*4 + 4*8;
$data .= "\x50\x4b\x06\x06";
$data .= pack("P", $centralDirectorySize);
$data .= "\x2d\x00"; // version 2.0 and MS-DOS compatible
$data .= "\x2d\x00"; // version 2.0 and MS-DOS compatible
$data .= "\x00\x00\x00\x00"; //number of disks
$data .= "\x00\x00\x00\x00"; //number of the disk with the start of the central directory
$data .= pack("P", count($this->files)); //total number of entries in the central directory on this disk
$data .= pack("P", count($this->files)); //total number of entries in the central directory
$data .= pack("P", $size); // size of the central directory
$data .= pack("P", $offset); //offset of start of central directory with respect to the starting disk number
// end of central directory locator
$data .= "\x50\x4b\x06\x07";
$data .= "\x00\x00\x00\x00";
$data .= pack("P", $this->writer->offset());
$data .= pack('V', 1); //number of disks
}
$data .= "\x50\x4b\x05\x06"; $data .= "\x50\x4b\x05\x06";
$data .= "\x00\x00"; //number of disks $data .= "\x00\x00"; //number of disks
$data .= "\x00\x00"; //number of the disk with the start of the central directory $data .= "\x00\x00"; //number of the disk with the start of the central directory
$data .= pack("v", count($this->files)); //total number of entries in the central directory on this disk $data .= $this->zip64 ? "\xFF\xFF" : pack("v", count($this->files)); //total number of entries in the central directory on this disk
$data .= pack("v", count($this->files)); //total number of entries in the central directory $data .= $this->zip64 ? "\xFF\xFF" : pack("v", count($this->files)); //total number of entries in the central directory
$data .= pack("V", $size); //size of the central directory $data .= $this->zip64 ? "\xFF\xFF\xFF\xFF" : pack("V", $size); // size of the central directory
$data .= pack("V", $offset); //offset of start of central directory with respect to the starting disk number $data .= $this->zip64 ? "\xFF\xFF\xFF\xFF" : pack("V", $offset); //offset of start of central directory with respect to the starting disk number
$data .= "\x0\x0"; //comment length $data .= "\x00\x00"; //comment length
$this->writer->write($data); $this->writer->write($data);
} }
} }

8
core/External/composer.json vendored Normal file

@ -0,0 +1,8 @@
{
"require": {
"twig/twig": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5"
}
}

2269
core/External/composer.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff

@ -24,11 +24,11 @@ class AesStream {
} }
} }
public function setInput($file) { public function setInputFile(string $file) {
$this->inputFile = $file; $this->inputFile = $file;
} }
public function setOutput($callback) { public function setOutput(callable $callback) {
$this->callback = $callback; $this->callback = $callback;
} }
@ -36,51 +36,13 @@ class AesStream {
$this->outputFile = $file; $this->outputFile = $file;
} }
private function add(string $a, int $b): string {
// counter $b is n = PHP_INT_SIZE bytes large
$b_arr = pack('I', $b);
$b_size = strlen($b_arr);
$a_size = strlen($a);
$prefix = "";
if ($a_size > $b_size) {
$prefix = substr($a, 0, $a_size - $b_size);
}
// xor last n bytes of $a with $b
$xor = substr($a, strlen($prefix), $b_size);
if (strlen($xor) !== strlen($b_arr)) {
var_dump($xor);
var_dump($b_arr);
die();
}
$xor = $this->xor($xor, $b_arr);
return $prefix . $xor;
}
private function xor(string $a, string $b): string {
$arr_a = str_split($a);
$arr_b = str_split($b);
if (strlen($a) !== strlen($b)) {
var_dump($a);
var_dump($b);
var_dump(range(0, strlen($a) - 1));
die();
}
return implode("", array_map(function($i) use ($arr_a, $arr_b) {
return chr(ord($arr_a[$i]) ^ ord($arr_b[$i]));
}, range(0, strlen($a) - 1)));
}
public function start(): bool { public function start(): bool {
if (!$this->inputFile) { if (!$this->inputFile) {
return false; return false;
} }
$blockSize = 16; $blockSize = 16;
$bitStrength = strlen($this->key) * 8; $aesMode = $this->getCipherMode();
$aesMode = "AES-$bitStrength-ECB";
$outputHandle = null; $outputHandle = null;
$inputHandle = fopen($this->inputFile, "rb"); $inputHandle = fopen($this->inputFile, "rb");
@ -91,25 +53,30 @@ class AesStream {
if ($this->outputFile !== null) { if ($this->outputFile !== null) {
$outputHandle = fopen($this->outputFile, "wb"); $outputHandle = fopen($this->outputFile, "wb");
if (!$outputHandle) { if (!$outputHandle) {
fclose($inputHandle);
return false; return false;
} }
} }
$counter = 0; set_time_limit(0);
while (!feof($inputHandle)) {
$chunk = fread($inputHandle, 4096);
$chunkSize = strlen($chunk);
for ($offset = 0; $offset < $chunkSize; $offset += $blockSize) {
$block = substr($chunk, $offset, $blockSize);
if (strlen($block) !== $blockSize) {
$padding = ($blockSize - strlen($block));
$block .= str_repeat(chr($padding), $padding);
}
$ivCounter = $this->add($this->iv, $counter + 1); $ivCounter = $this->iv;
$encrypted = substr(openssl_encrypt($ivCounter, $aesMode, $this->key, OPENSSL_RAW_DATA), 0, $blockSize); $modulo = \gmp_init("0x1" . str_repeat("00", $blockSize), 16);
$encrypted = $this->xor($encrypted, $block);
if (is_callable($this->callback)) { while (!feof($inputHandle)) {
$chunk = fread($inputHandle, 65536);
$chunkSize = strlen($chunk);
if ($chunkSize > 0) {
$blockCount = intval(ceil($chunkSize / $blockSize));
$encrypted = openssl_encrypt($chunk, $aesMode, $this->key, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $ivCounter);
$ivNumber = \gmp_init(bin2hex($ivCounter), 16);
$ivNumber = \gmp_add($ivNumber, $blockCount);
$ivNumber = \gmp_mod($ivNumber, $modulo);
$ivNumber = str_pad(\gmp_strval($ivNumber, 16), $blockSize * 2, "0", STR_PAD_LEFT);
$ivCounter = hex2bin($ivNumber);
if ($this->callback !== null) {
call_user_func($this->callback, $encrypted); call_user_func($this->callback, $encrypted);
} }
@ -123,4 +90,17 @@ class AesStream {
if ($outputHandle) fclose($outputHandle); if ($outputHandle) fclose($outputHandle);
return true; return true;
} }
public function getCipherMode(): string {
$bitStrength = strlen($this->key) * 8;
return "aes-$bitStrength-ctr";
}
public function getKey(): string {
return $this->key;
}
public function getIV(): string {
return $this->iv;
}
} }

@ -27,9 +27,6 @@ namespace Objects {
public function getCode(): string { return $this->langCode; } public function getCode(): string { return $this->langCode; }
public function getShortCode() { return substr($this->langCode, 0, 2); } public function getShortCode() { return substr($this->langCode, 0, 2); }
public function getName() { return $this->langName; } public function getName() { return $this->langName; }
public function getIconPath() { return "/img/icons/lang/$this->langCode.gif"; }
public function getEntries() { return $this->entries; }
public function getModules() { return $this->modules; }
/** /**
* @param $module LanguageModule class or object * @param $module LanguageModule class or object

@ -4,6 +4,7 @@ namespace Objects;
use DateTime; use DateTime;
use \Driver\SQL\Condition\Compare; use \Driver\SQL\Condition\Compare;
use Driver\SQL\Expression\CurrentTimeStamp;
use Exception; use Exception;
use External\JWT; use External\JWT;
@ -118,7 +119,7 @@ class Session extends ApiObject {
return false; return false;
} }
public function destroy() { public function destroy(): bool {
return $this->user->getSQL()->update("Session") return $this->user->getSQL()->update("Session")
->set("active", false) ->set("active", false)
->where(new Compare("Session.uid", $this->sessionId)) ->where(new Compare("Session.uid", $this->sessionId))
@ -126,21 +127,26 @@ class Session extends ApiObject {
->execute(); ->execute();
} }
public function update() { public function update(): bool {
$this->updateMetaData(); $this->updateMetaData();
$minutes = Session::DURATION; $minutes = Session::DURATION;
$sql = $this->user->getSQL(); $sql = $this->user->getSQL();
return $sql->update("Session") return
->set("Session.expires", (new DateTime())->modify("+$minutes minute")) $sql->update("User")
->set("Session.ipAddress", $this->ipAddress) ->set("last_online", new CurrentTimeStamp())
->set("Session.os", $this->os) ->where(new Compare("uid", $this->user->getId()))
->set("Session.browser", $this->browser) ->execute() &&
->set("Session.data", json_encode($_SESSION)) $sql->update("Session")
->set("Session.csrf_token", $this->csrfToken) ->set("Session.expires", (new DateTime())->modify("+$minutes minute"))
->where(new Compare("Session.uid", $this->sessionId)) ->set("Session.ipAddress", $this->ipAddress)
->where(new Compare("Session.user_id", $this->user->getId())) ->set("Session.os", $this->os)
->execute(); ->set("Session.browser", $this->browser)
->set("Session.data", json_encode($_SESSION))
->set("Session.csrf_token", $this->csrfToken)
->where(new Compare("Session.uid", $this->sessionId))
->where(new Compare("Session.user_id", $this->user->getId()))
->execute();
} }
public function getCsrfToken(): string { public function getCsrfToken(): string {

@ -17,7 +17,9 @@ class User extends ApiObject {
private ?Session $session; private ?Session $session;
private int $uid; private int $uid;
private string $username; private string $username;
private string $fullName;
private ?string $email; private ?string $email;
private ?string $profilePicture;
private Language $language; private Language $language;
private array $groups; private array $groups;
@ -55,6 +57,7 @@ class User extends ApiObject {
public function getId(): int { return $this->uid; } public function getId(): int { return $this->uid; }
public function isLoggedIn(): bool { return $this->loggedIn; } public function isLoggedIn(): bool { return $this->loggedIn; }
public function getUsername(): string { return $this->username; } public function getUsername(): string { return $this->username; }
public function getFullName(): string { return $this->fullName; }
public function getEmail(): ?string { return $this->email; } public function getEmail(): ?string { return $this->email; }
public function getSQL(): ?SQL { return $this->sql; } public function getSQL(): ?SQL { return $this->sql; }
public function getLanguage(): Language { return $this->language; } public function getLanguage(): Language { return $this->language; }
@ -63,6 +66,7 @@ class User extends ApiObject {
public function getConfiguration(): Configuration { return $this->configuration; } public function getConfiguration(): Configuration { return $this->configuration; }
public function getGroups(): array { return $this->groups; } public function getGroups(): array { return $this->groups; }
public function hasGroup(int $group): bool { return isset($this->groups[$group]); } public function hasGroup(int $group): bool { return isset($this->groups[$group]); }
public function getProfilePicture() : ?string { return $this->profilePicture; }
public function __debugInfo(): array { public function __debugInfo(): array {
$debugInfo = array( $debugInfo = array(
@ -83,6 +87,8 @@ class User extends ApiObject {
return array( return array(
'uid' => $this->uid, 'uid' => $this->uid,
'name' => $this->username, 'name' => $this->username,
'fullName' => $this->fullName,
'profilePicture' => $this->profilePicture,
'email' => $this->email, 'email' => $this->email,
'groups' => $this->groups, 'groups' => $this->groups,
'language' => $this->language->jsonSerialize(), 'language' => $this->language->jsonSerialize(),
@ -99,8 +105,10 @@ class User extends ApiObject {
$this->uid = 0; $this->uid = 0;
$this->username = ''; $this->username = '';
$this->email = ''; $this->email = '';
$this->groups = [];
$this->loggedIn = false; $this->loggedIn = false;
$this->session = null; $this->session = null;
$this->profilePicture = null;
} }
public function logout(): bool { public function logout(): bool {
@ -137,9 +145,9 @@ class User extends ApiObject {
* @param bool $sessionUpdate update session information, including session's lifetime and browser information * @param bool $sessionUpdate update session information, including session's lifetime and browser information
* @return bool true, if the data could be loaded * @return bool true, if the data could be loaded
*/ */
public function readData($userId, $sessionId, $sessionUpdate = true): bool { public function readData($userId, $sessionId, bool $sessionUpdate = true): bool {
$res = $this->sql->select("User.name", "User.email", $res = $this->sql->select("User.name", "User.email", "User.fullName", "User.profilePicture",
"Language.uid as langId", "Language.code as langCode", "Language.name as langName", "Language.uid as langId", "Language.code as langCode", "Language.name as langName",
"Session.data", "Session.stay_logged_in", "Session.csrf_token", "Group.uid as groupId", "Group.name as groupName") "Session.data", "Session.stay_logged_in", "Session.csrf_token", "Group.uid as groupId", "Group.name as groupName")
->from("User") ->from("User")
@ -162,7 +170,10 @@ class User extends ApiObject {
$csrfToken = $row["csrf_token"]; $csrfToken = $row["csrf_token"];
$this->username = $row['name']; $this->username = $row['name'];
$this->email = $row["email"]; $this->email = $row["email"];
$this->fullName = $row["fullName"];
$this->uid = $userId; $this->uid = $userId;
$this->profilePicture = $row["profilePicture"];
$this->session = new Session($this, $sessionId, $csrfToken); $this->session = new Session($this, $sessionId, $csrfToken);
$this->session->setData(json_decode($row["data"] ?? '{}')); $this->session->setData(json_decode($row["data"] ?? '{}'));
$this->session->stayLoggedIn($this->sql->parseBool(["stay_logged_in"])); $this->session->stayLoggedIn($this->sql->parseBool(["stay_logged_in"]));
@ -183,16 +194,14 @@ class User extends ApiObject {
} }
private function parseCookies() { private function parseCookies() {
if(isset($_COOKIE['session']) if(isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
&& is_string($_COOKIE['session'])
&& !empty($_COOKIE['session'])) {
try { try {
$token = $_COOKIE['session']; $token = $_COOKIE['session'];
$settings = $this->configuration->getSettings(); $settings = $this->configuration->getSettings();
$decoded = (array)JWT::decode($token, $settings->getJwtSecret()); $decoded = (array)JWT::decode($token, $settings->getJwtSecret());
if(!is_null($decoded)) { if(!is_null($decoded)) {
$userId = (isset($decoded['userId']) ? $decoded['userId'] : NULL); $userId = ($decoded['userId'] ?? NULL);
$sessionId = (isset($decoded['sessionId']) ? $decoded['sessionId'] : NULL); $sessionId = ($decoded['sessionId'] ?? NULL);
if(!is_null($userId) && !is_null($sessionId)) { if(!is_null($userId) && !is_null($sessionId)) {
$this->readData($userId, $sessionId); $this->readData($userId, $sessionId);
} }
@ -226,7 +235,8 @@ class User extends ApiObject {
return true; return true;
} }
$res = $this->sql->select("ApiKey.user_id as uid", "User.name", "User.email", "User.confirmed", $res = $this->sql->select("ApiKey.user_id as uid", "User.name", "User.fullName", "User.email",
"User.confirmed", "User.profilePicture",
"Language.uid as langId", "Language.code as langCode", "Language.name as langName", "Language.uid as langId", "Language.code as langCode", "Language.name as langName",
"Group.uid as groupId", "Group.name as groupName") "Group.uid as groupId", "Group.name as groupName")
->from("ApiKey") ->from("ApiKey")
@ -240,8 +250,8 @@ class User extends ApiObject {
->execute(); ->execute();
$success = ($res !== FALSE); $success = ($res !== FALSE);
if($success) { if ($success) {
if(empty($res)) { if (empty($res) || !is_array($res)) {
$success = false; $success = false;
} else { } else {
$row = $res[0]; $row = $res[0];
@ -251,7 +261,9 @@ class User extends ApiObject {
$this->uid = $row['uid']; $this->uid = $row['uid'];
$this->username = $row['name']; $this->username = $row['name'];
$this->fullName = $row["fullName"];
$this->email = $row['email']; $this->email = $row['email'];
$this->profilePicture = $row["profilePicture"];
if(!is_null($row['langId'])) { if(!is_null($row['langId'])) {
$this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName'])); $this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName']));

4
core/Templates/404.twig Normal file

@ -0,0 +1,4 @@
{% extends "base.twig" %}
{% block body %}
<b>Not found</b>
{% endblock %}

@ -0,0 +1,36 @@
{% extends "base.twig" %}
{% block head %}
<script src="/js/jquery.min.js" nonce="{{ site.csp.nonce }}"></script>
<script src="/js/script.js" nonce="{{ site.csp.nonce }}"></script>
<script src="/js/account.js" nonce="{{ site.csp.nonce }}"></script>
<link rel="stylesheet" href="/css/bootstrap.min.css" nonce="{{ site.csp.nonce }}">
<script src="/js/bootstrap.bundle.min.js" nonce="{{ site.csp.nonce }}"></script>
<link rel="stylesheet" href="/css/fontawesome.min.css" nonce="{{ site.csp.nonce }}">
<link rel="stylesheet" href="/css/account.css" nonce="{{ site.csp.nonce }}">
<title>Account - {{ view_title }}</title>
{% if site.recaptcha.enabled %}
<script src="https://www.google.com/recaptcha/api.js?render={{ site.recaptcha.key }}" nonce="{{ site.csp.nonce }}"></script>
{% endif %}
{% endblock %}
{% block body %}
<div class="container mt-5">
<div class="row">
<div class="col-md-3 py-5 bg-primary text-white text-center" style='border-top-left-radius:.4em;border-bottom-left-radius:.4em;margin-left: auto'>
<div class="card-body">
<i class="fas fa-{{ view_icon }} fa-3x"></i>
<h2 class="py-3">{{ view_title }}</h2>
<p>{{ view_description }}</p>
</div>
</div>
<div class="col-md-5 pt-5 pb-2 border border-info" style='border-top-right-radius:.4em;border-bottom-right-radius:.4em;margin-right:auto'>
{% block view_content %}{% endblock %}
<div class='alert mt-2' style='display:none' id='alertMessage'></div>
</div>
</div>
</div>
{% if site.recaptcha.enabled %}
<input type='hidden' value='{{ site.recaptcha.key }}' id='siteKey' />
{% endif %}
{% endblock %}

@ -0,0 +1,46 @@
{% extends "account.twig" %}
{% set view_title = 'Invitation' %}
{% set view_icon = 'user-check' %}
{% set view_description = 'Finnish your account registration by choosing a password.' %}
{% block view_content %}
{% if not view.success %}
<div class="alert alert-danger" role="alert">{{ view.message }}</div>
<a href='/login' class='btn btn-primary'>Back to login</a>
{% else %}
<h4 class="pb-4">Please fill with your details</h4>
<form>
<input name='token' id='token' type='hidden' value='{{ view.token }}'/>
<div class="input-group">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-hashtag"></i></span>
</div>
<input id="username" name="username" placeholder="Username" class="form-control" type="text" maxlength="32" value='{{ view.invited_user.name }}' disabled>
</div>
<div class="input-group mt-3">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-at"></i></span>
</div>
<input type="email" name='email' id='email' class="form-control" placeholder="Email" maxlength="64" value='{{ view.invited_user.email }}' disabled>
</div>
<div class="input-group mt-3">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-key"></i></span>
</div>
<input type="password" autocomplete='new-password' name='password' id='password' class="form-control" placeholder="Password">
</div>
<div class="input-group mt-3">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-key"></i></span>
</div>
<input type="password" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class="form-control" placeholder="Confirm Password">
</div>
<div class="input-group mt-3">
<button type="button" class="btn btn-success" id='btnAcceptInvite'>Submit</button>
</div>
</form>
{% endif %}
{% endblock %}

@ -0,0 +1,37 @@
{% extends "account.twig" %}
{% set view_title = 'Confirm Email' %}
{% set view_icon = 'user-check' %}
{% set view_description = 'Request a password reset, once you got the e-mail address, you can choose a new password' %}
{% block view_content %}
<noscript>
<div class="alert alert-danger">Javascript is required</div>
</noscript>
<div class="alert alert-info" id="confirm-status">
Confirming email… <i class="fas fa-spinner fa-spin"></i>
</div>
<a href='/login'><button class='btn btn-primary' style='position: absolute; bottom: 10px' type='button'>Proceed to Login</button></a>
<script nonce="{{ site.csp.nonce }}">
$(document).ready(function() {
let token = jsCore.getParameter("token");
let confirmStatus = $("#confirm-status");
if (token) {
jsCore.apiCall("/user/confirmEmail", { token: token }, (res) => {
confirmStatus.removeClass("alert-info");
if (!res.success) {
confirmStatus.addClass("alert-danger");
confirmStatus.text("Error confirming e-mail address: " + res.msg);
} else {
confirmStatus.addClass("alert-success");
confirmStatus.text("Your e-mail address was successfully confirmed, you may now log in.");
}
});
} else {
confirmStatus.removeClass("alert-info");
confirmStatus.addClass("alert-danger");
confirmStatus.text("The link you visited is no longer valid");
}
});
</script>
{% endblock %}

@ -0,0 +1,50 @@
{% extends "account.twig" %}
{% set view_title = 'Registration' %}
{% set view_icon = 'user-plus' %}
{% set view_description = 'Create a new account' %}
{% block view_content %}
{% if not view.success %}
<div class="alert alert-danger" role="alert">{{ view.message }}</div>
<a href='/login' class='btn btn-primary'>Go back</a>
{% else %}
<h4 class="pb-4">Please fill with your details</h4>
<form>
<div class="input-group">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-hashtag"></i></span>
</div>
<input id="username" autocomplete='username' name="username" placeholder="Username" class="form-control" type="text" maxlength="32">
</div>
<div class="input-group mt-3">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-at"></i></span>
</div>
<input type="email" autocomplete='email' name='email' id='email' class="form-control" placeholder="Email" maxlength="64">
</div>
<div class="input-group mt-3">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-key"></i></span>
</div>
<input type="password" autocomplete='new-password' name='password' id='password' class="form-control" placeholder="Password">
</div>
<div class="input-group mt-3">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-key"></i></span>
</div>
<input type="password" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class="form-control" placeholder="Confirm Password">
</div>
<div class="input-group mt-3">
<button type="button" class="btn btn-primary" id='btnRegister'>Submit</button>
<a href='/login' style='margin-left: 10px'>
<button class='btn btn-secondary' type='button'>
Back to Login
</button>
</a>
</div>
</form>
{% endif %}
{% endblock %}

@ -0,0 +1,27 @@
{% extends "account.twig" %}
{% set view_title = 'Resend Confirm Email' %}
{% set view_icon = 'envelope' %}
{% set view_description = 'Request a new confirmation email to finalize the account creation' %}
{% block view_content %}
<p class='lead'>Enter your E-Mail address, to receive a new e-mail to confirm your registration.</p>
<form>
<div class="input-group">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-at"></i></span>
</div>
<input id="email" autocomplete='email' name="email" placeholder="E-Mail address" class="form-control" type="email" maxlength="64" />
</div>
<div class="input-group mt-2" style='position: absolute;bottom: 15px'>
<button id='btnResendConfirmEmail' class='btn btn-primary'>
Request
</button>
<a href='/login' style='margin-left: 10px'>
<button class='btn btn-secondary' type='button'>
Back to Login
</button>
</a>
</div>
</form>
{% endblock %}

@ -0,0 +1,59 @@
{% extends "account.twig" %}
{% set view_title = 'Reset Password' %}
{% set view_icon = 'user-lock' %}
{% set view_description = 'Request a password reset, once you got the e-mail address, you can choose a new password' %}
{% block view_content %}
{% if view.token %}
{% if not view.success %}
<div class="alert alert-danger" role="alert">{{ view.message }}</div>
<a href='/resetPassword' class='btn btn-primary'>Go back</a>
{% else %}
<h4 class="pb-4">Choose a new password</h4>
<form>
<input name='token' id='token' type='hidden' value='{{ view.token }}'/>
<div class="input-group mt-3">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-key"></i></span>
</div>
<input type="password" autocomplete='new-password' name='password' id='password' class="form-control" placeholder="Password">
</div>
<div class="input-group mt-3">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-key"></i></span>
</div>
<input type="password" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class="form-control" placeholder="Confirm Password">
</div>
<div class="input-group mt-3">
<button type="button" class="btn btn-primary" id='btnResetPassword'>Submit</button>
<a href='/login' style='margin-left: 10px; display: none' id='backToLogin'>
<button class='btn btn-success' type='button'>
Back to Login
</button>
</a>
</div>
</form>
{% endif %}
{% else %}
<p class='lead'>Enter your E-Mail address, to receive a password reset token.</p>
<form>
<div class="input-group">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-at"></i></span>
</div>
<input id="email" autocomplete='email' name="email" placeholder="E-Mail address" class="form-control" type="email" maxlength="64" />
</div>
<div class="input-group mt-2" style='position: absolute;bottom: 15px'>
<button id='btnRequestPasswordReset' class='btn btn-primary'>
Request
</button>
<a href='/login' style='margin-left: 10px'>
<button class='btn btn-secondary' type='button'>
Back to Login
</button>
</a>
</div>
</form>
{% endif %}
{% endblock %}

12
core/Templates/admin.twig Normal file

@ -0,0 +1,12 @@
{% extends "base.twig" %}
{% block head %}
<title>{{ site.name }} - Administration</title>
<script src="/js/fontawesome-all.min.js" nonce="{{ site.csp.nonce }}"></script>
{% endblock %}
{% block body %}
<noscript>You need Javascript enabled to run this app</noscript>
<div class="wrapper" id="root"></div>
<script src="/js/admin.min.js" nonce="{{ site.csp.nonce }}"></script>
{% endblock %}

15
core/Templates/base.twig Normal file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="{{ user.lang }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="format-detection" content="telephone=yes" />
{% block head %}
<title>{{ site.title }}</title>
{% endblock %}
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>

@ -0,0 +1,8 @@
Hello {{ username }},<br><br>
You were invited to create an account on {{ site_name }}. Please click on the following link to confirm your email address and complete your registration by choosing a new password.
If you want to decline the invitation, you can simply ignore this email. The link is valid for {{ valid_time }}:<br><br>
<a href="{{ link }}">{{ link }}</a><br><br>
Best Regards<br>
{{ site_name }} Administration

@ -0,0 +1,8 @@
Hello {{ username }},<br><br>
You recently created an account on {{ site_name }}. Please click on the following link to confirm your email address and complete your registration.<br>
If you haven't registered an account, you can simply ignore this email. The link is valid for {{ valid_time }}:<br><br>
<a href="{{ link }}">{{ link }}</a><br><br>
Best Regards<br>
{{ site_name }} Administration

@ -0,0 +1,8 @@
Hello {{ username }},<br>
you requested a password reset on {{ site_name }}. Please click on the following link to choose a new password. <br>
If this request was not intended, you can simply ignore the email. The Link is valid for {{ valid_time }}:<br><br>
<a href="{{ link }}">{{ link }}</a><br><br>
Best Regards<br>
{{ site_name }} Administration

@ -0,0 +1,7 @@
{% extends "base.twig" %}
{% block head %}
<meta http-equiv="refresh" content="0;url={{ url }}">
{% endblock %}
{% block body %}
You will be automatically redirected to <b>{{ url }}</b>. If that doesn't work, click <a href="{{ url }}">here</a>.
{% endblock %}

@ -1,89 +0,0 @@
<?php
namespace Views\Account;
use Elements\Document;
use Elements\View;
class AcceptInvite extends AccountView {
private bool $success;
private string $message;
private array $invitedUser;
public function __construct(Document $document, $loadView = true) {
parent::__construct($document, $loadView);
$this->title = "Invitation";
$this->description = "Finnish your account registration by choosing a password.";
$this->icon = "user-check";
$this->success = false;
$this->message = "No content";
$this->invitedUser = array();
}
public function loadView() {
parent::loadView();
if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
$req = new \Api\User\CheckToken($this->getDocument()->getUser());
$this->success = $req->execute(array("token" => $_GET["token"]));
if ($this->success) {
if (strcmp($req->getResult()["token"]["type"], "invite") !== 0) {
$this->success = false;
$this->message = "The given token has a wrong type.";
} else {
$this->invitedUser = $req->getResult()["user"];
}
} else {
$this->message = "Error confirming e-mail address: " . $req->getLastError();
}
} else {
$this->success = false;
$this->message = "The link you visited is no longer valid";
}
}
protected function getAccountContent() {
if (!$this->success) {
return $this->createErrorText($this->message);
}
$token = htmlspecialchars($_GET["token"], ENT_QUOTES);
$username = $this->invitedUser["name"];
$emailAddress = $this->invitedUser["email"];
return "<h4 class=\"pb-4\">Please fill with your details</h4>
<form>
<input name='token' id='token' type='hidden' value='$token'/>
<div class=\"input-group\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-hashtag\"></i></span>
</div>
<input id=\"username\" name=\"username\" placeholder=\"Username\" class=\"form-control\" type=\"text\" maxlength=\"32\" value='$username' disabled>
</div>
<div class=\"input-group mt-3\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>
</div>
<input type=\"email\" name='email' id='email' class=\"form-control\" placeholder=\"Email\" maxlength=\"64\" value='$emailAddress' disabled>
</div>
<div class=\"input-group mt-3\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
</div>
<input type=\"password\" autocomplete='new-password' name='password' id='password' class=\"form-control\" placeholder=\"Password\">
</div>
<div class=\"input-group mt-3\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
</div>
<input type=\"password\" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
</div>
<div class=\"input-group mt-3\">
<button type=\"button\" class=\"btn btn-success\" id='btnAcceptInvite'>Submit</button>
</div>
</form>";
}
}

@ -1,61 +0,0 @@
<?php
namespace Views\Account;
use Elements\Document;
use Elements\View;
abstract class AccountView extends View {
protected string $description;
protected string $icon;
public function __construct(Document $document, $loadView = true) {
parent::__construct($document, $loadView);
$this->description = "";
$this->icon = "image";
}
public function loadView() {
parent::loadView();
$document = $this->getDocument();
$settings = $document->getUser()->getConfiguration()->getSettings();
if ($settings->isRecaptchaEnabled()) {
$document->getHead()->loadGoogleRecaptcha($settings->getRecaptchaSiteKey());
}
}
public function getCode(): string {
$html = parent::getCode();
$content = $this->getAccountContent();
$icon = $this->createIcon($this->icon, "fas", "fa-3x");
$html .= "<div class=\"container mt-5\">
<div class=\"row\">
<div class=\"col-md-3 py-5 bg-primary text-white text-center\" style='border-top-left-radius:.4em;border-bottom-left-radius:.4em;margin-left: auto'>
<div class=\"card-body\">
$icon
<h2 class=\"py-3\">$this->title</h2>
<p>$this->description</p>
</div>
</div>
<div class=\"col-md-5 pt-5 pb-2 border border-info\" style='border-top-right-radius:.4em;border-bottom-right-radius:.4em;margin-right:auto'>
$content
<div class='alert mt-2' style='display:none' id='alertMessage'></div>
</div>
</div>
</div>";
$settings = $this->getDocument()->getUser()->getConfiguration()->getSettings();
if ($settings->isRecaptchaEnabled()) {
$siteKey = $settings->getRecaptchaSiteKey();
$html .= "<input type='hidden' value='$siteKey' id='siteKey' />";
}
return $html;
}
protected abstract function getAccountContent();
}

@ -1,55 +0,0 @@
<?php
namespace Views\Account;
use Elements\Document;
use Elements\Script;
class ConfirmEmail extends AccountView {
public function __construct(Document $document, $loadView = true) {
parent::__construct($document, $loadView);
$this->title = "Confirm Email";
$this->description = "Request a password reset, once you got the e-mail address, you can choose a new password";
$this->icon = "user-check";
}
public function loadView() {
parent::loadView();
$this->getDocument()->getHead()->addScript(Script::MIME_TEXT_JAVASCRIPT, "", '
$(document).ready(function() {
var token = jsCore.getParameter("token");
if (token) {
jsCore.apiCall("/user/confirmEmail", { token: token }, (res) => {
$("#confirm-status").removeClass("alert-info");
if (!res.success) {
$("#confirm-status").addClass("alert-danger");
$("#confirm-status").text("Error confirming e-mail address: " + res.msg);
} else {
$("#confirm-status").addClass("alert-success");
$("#confirm-status").text("Your e-mail address was successfully confirmed, you may now log in.");
}
});
} else {
$("#confirm-status").removeClass("alert-info");
$("#confirm-status").addClass("alert-danger");
$("#confirm-status").text("The link you visited is no longer valid");
}
});'
);
}
protected function getAccountContent() {
$spinner = $this->createIcon("spinner");
$html = "<noscript><div class=\"alert alert-danger\">Javascript is required</div></noscript>
<div class=\"alert alert-info\" id=\"confirm-status\">
Confirming email… $spinner
</div>";
$html .= "<a href='/login'><button class='btn btn-primary' style='position: absolute; bottom: 10px' type='button'>Proceed to Login</button></a>";
return $html;
}
}

@ -1,70 +0,0 @@
<?php
namespace Views\Account;
use Elements\Document;
class Register extends AccountView {
public function __construct(Document $document, $loadView = true) {
parent::__construct($document, $loadView);
$this->title = "Registration";
$this->description = "Create a new account";
$this->icon = "user-plus";
}
public function getAccountContent() {
$user = $this->getDocument()->getUser();
if ($user->isLoggedIn()) {
header(302);
header("Location: /");
die("You are already logged in.");
}
$settings = $user->getConfiguration()->getSettings();
if (!$settings->isRegistrationAllowed()) {
return $this->createErrorText(
"Registration is not enabled on this website. If you are an administrator,
goto <a href=\"/admin/settings\">/admin/settings</a>, to enable the user registration"
);
}
return "<h4 class=\"pb-4\">Please fill with your details</h4>
<form>
<div class=\"input-group\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-hashtag\"></i></span>
</div>
<input id=\"username\" autocomplete='username' name=\"username\" placeholder=\"Username\" class=\"form-control\" type=\"text\" maxlength=\"32\">
</div>
<div class=\"input-group mt-3\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>
</div>
<input type=\"email\" autocomplete='email' name='email' id='email' class=\"form-control\" placeholder=\"Email\" maxlength=\"64\">
</div>
<div class=\"input-group mt-3\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
</div>
<input type=\"password\" autocomplete='new-password' name='password' id='password' class=\"form-control\" placeholder=\"Password\">
</div>
<div class=\"input-group mt-3\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
</div>
<input type=\"password\" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
</div>
<div class=\"input-group mt-3\">
<button type=\"button\" class=\"btn btn-primary\" id='btnRegister'>Submit</button>
<a href='/login' style='margin-left: 10px'>
<button class='btn btn-secondary' type='button'>
Back to Login
</button>
</a>
</div>
</form>";
}
}

@ -1,39 +0,0 @@
<?php
namespace Views\Account;
use Elements\Document;
class ResendConfirmEmail extends AccountView {
public function __construct(Document $document, $loadView = true) {
parent::__construct($document, $loadView);
$this->title = "Resend Confirm Email";
$this->description = "Request a new confirmation email to finalize the account creation";
$this->icon = "envelope";
}
protected function getAccountContent() {
return "<p class='lead'>Enter your E-Mail address, to receive a new e-mail to confirm your registration.</p>
<form>
<div class=\"input-group\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>
</div>
<input id=\"email\" autocomplete='email' name=\"email\" placeholder=\"E-Mail address\" class=\"form-control\" type=\"email\" maxlength=\"64\" />
</div>
<div class=\"input-group mt-2\" style='position: absolute;bottom: 15px'>
<button id='btnResendConfirmEmail' class='btn btn-primary'>
Request
</button>
<a href='/login' style='margin-left: 10px'>
<button class='btn btn-secondary' type='button'>
Back to Login
</button>
</a>
</div>
";
}
}

@ -1,94 +0,0 @@
<?php
namespace Views\Account;
use Elements\Document;
class ResetPassword extends AccountView {
private bool $success;
private string $message;
private ?string $token;
public function __construct(Document $document, $loadView = true) {
parent::__construct($document, $loadView);
$this->title = "Reset Password";
$this->description = "Request a password reset, once you got the e-mail address, you can choose a new password";
$this->icon = "user-lock";
$this->success = true;
$this->message = "";
$this->token = NULL;
}
public function loadView() {
parent::loadView();
if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
$this->token = $_GET["token"];
$req = new \Api\User\CheckToken($this->getDocument()->getUser());
$this->success = $req->execute(array("token" => $_GET["token"]));
if ($this->success) {
if (strcmp($req->getResult()["token"]["type"], "password_reset") !== 0) {
$this->success = false;
$this->message = "The given token has a wrong type.";
}
} else {
$this->message = "Error requesting password reset: " . $req->getLastError();
}
}
}
protected function getAccountContent() {
if (!$this->success) {
$html = $this->createErrorText($this->message);
if ($this->token !== null) {
$html .= "<a href='/resetPassword' class='btn btn-primary'>Go back</a>";
}
return $html;
}
if ($this->token === null) {
return "<p class='lead'>Enter your E-Mail address, to receive a password reset token.</p>
<form>
<div class=\"input-group\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>
</div>
<input id=\"email\" autocomplete='email' name=\"email\" placeholder=\"E-Mail address\" class=\"form-control\" type=\"email\" maxlength=\"64\" />
</div>
<div class=\"input-group mt-2\" style='position: absolute;bottom: 15px'>
<button id='btnRequestPasswordReset' class='btn btn-primary'>
Request
</button>
<a href='/login' style='margin-left: 10px'>
<button class='btn btn-secondary' type='button'>
Back to Login
</button>
</a>
</div>
";
} else {
return "<h4 class=\"pb-4\">Choose a new password</h4>
<form>
<input name='token' id='token' type='hidden' value='$this->token'/>
<div class=\"input-group mt-3\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
</div>
<input type=\"password\" autocomplete='new-password' name='password' id='password' class=\"form-control\" placeholder=\"Password\">
</div>
<div class=\"input-group mt-3\">
<div class=\"input-group-append\">
<span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
</div>
<input type=\"password\" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
</div>
<div class=\"input-group mt-3\">
<button type=\"button\" class=\"btn btn-success\" id='btnResetPassword'>Submit</button>
</div>
</form>";
}
}
}

@ -1,20 +0,0 @@
<?php
namespace Views\Admin;
use Elements\Body;
use Elements\Script;
class AdminDashboardBody extends Body {
public function __construct($document) {
parent::__construct($document);
}
public function getCode(): string {
$html = parent::getCode();
$script = new Script(Script::MIME_TEXT_JAVASCRIPT, "/js/admin.min.js");
$html .= "<body><div class=\"wrapper\" id=\"root\">$script</div></body>";
return $html;
}
}

@ -1,72 +0,0 @@
<?php
namespace Views\Admin;
use Elements\Body;
use Elements\Link;
use Elements\Script;
use Views\LanguageFlags;
class LoginBody extends Body {
public function __construct($document) {
parent::__construct($document);
}
public function loadView() {
parent::loadView();
$head = $this->getDocument()->getHead();
$head->loadJQuery();
$head->loadBootstrap();
$head->addJS(Script::CORE);
$head->addCSS(Link::CORE);
$head->addJS(Script::ACCOUNT);
$head->addCSS(Link::ACCOUNT);
}
public function getCode(): string {
$html = parent::getCode();
$username = L("Username");
$password = L("Password");
$login = L("Login");
$backToStartPage = L("Back to Start Page");
$stayLoggedIn = L("Stay logged in");
$flags = $this->load(LanguageFlags::class);
$iconBack = $this->createIcon("arrow-circle-left");
$domain = $_SERVER['HTTP_HOST'];
$protocol = getProtocol();
$html .= "
<body>
<div class=\"container mt-4\">
<div class=\"title text-center\">
<h2>Admin Control Panel</h2>
</div>
<div class=\"row\">
<div class=\"col-lg-6 col-12 m-auto\">
<form class=\"loginForm\">
<label for=\"username\">$username</label>
<input type=\"text\" class=\"form-control\" name=\"username\" id=\"username\" placeholder=\"$username\" required autofocus />
<label for=\"password\">$password</label>
<input type=\"password\" class=\"form-control\" name=\"password\" id=\"password\" placeholder=\"$password\" required />
<div class=\"form-check\">
<input type=\"checkbox\" class=\"form-check-input\" id=\"stayLoggedIn\" name=\"stayLoggedIn\">
<label class=\"form-check-label\" for=\"stayLoggedIn\">$stayLoggedIn</label>
</div>
<button class=\"btn btn-lg btn-primary btn-block\" id=\"btnLogin\" type=\"button\">$login</button>
<div class=\"alert alert-danger\" style='display:none' role=\"alert\" id=\"alertMessage\"></div>
<span class=\"flags position-absolute\">$flags</span>
</form>
<div class=\"p-1\">
<a href=\"$protocol://$domain\">$iconBack&nbsp;$backToStartPage</a>
</div>
</div>
</div>
</div>
</body>";
return $html;
}
}

@ -1,13 +0,0 @@
<?php
namespace Views;
use Elements\View;
class View404 extends View {
public function getCode(): string {
return parent::getCode() . "<b>Not found</b>";
}
};

@ -1,14 +1,18 @@
<?php <?php
define("WEBBASE_VERSION", "1.3.0"); require_once "External/vendor/autoload.php";
define("WEBBASE_VERSION", "1.3.0-beta");
spl_autoload_extensions(".php"); spl_autoload_extensions(".php");
spl_autoload_register(function($class) { spl_autoload_register(function($class) {
$full_path = WEBROOT . "/" . getClassPath($class); if (!class_exists($class)) {
if (file_exists($full_path)) { $full_path = WEBROOT . "/" . getClassPath($class);
include_once $full_path; if (file_exists($full_path)) {
} else { include_once $full_path;
include_once getClassPath($class, false); } else {
include_once getClassPath($class, false);
}
} }
}); });
@ -24,6 +28,13 @@ function getProtocol(): string {
return $isSecure ? 'https' : 'http'; return $isSecure ? 'https' : 'http';
} }
function uuidv4(): string {
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40); // set version to 0100
$data[8] = chr(ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
function generateRandomString($length, $type = "ascii"): string { function generateRandomString($length, $type = "ascii"): string {
$randomString = ''; $randomString = '';
@ -31,12 +42,14 @@ function generateRandomString($length, $type = "ascii"): string {
$uppercase = strtoupper($lowercase); $uppercase = strtoupper($lowercase);
$digits = "0123456789"; $digits = "0123456789";
$hex = $digits . substr($lowercase, 0, 6); $hex = $digits . substr($lowercase, 0, 6);
$ascii = $lowercase . $uppercase . $digits; $ascii = $uppercase . $lowercase . $digits;
if ($length > 0) { if ($length > 0) {
$type = strtolower($type); $type = strtolower($type);
if ($type === "hex") { if ($type === "hex") {
$charset = $hex; $charset = $hex;
} else if ($type === "base64") {
$charset = $ascii . "/+";
} else { } else {
$charset = $ascii; $charset = $ascii;
} }
@ -136,6 +149,13 @@ function urlId($str) {
return urlencode(htmlspecialchars(preg_replace("[: ]","-", $str))); return urlencode(htmlspecialchars(preg_replace("[: ]","-", $str)));
} }
function html_attributes(array $attributes): string {
return implode(" ", array_map(function ($key) use ($attributes) {
$value = $attributes[$key];
return "$key=\"$value\"";
}, array_keys($attributes)));
}
function getClassPath($class, $suffix = true): string { function getClassPath($class, $suffix = true): string {
$path = str_replace('\\', '/', $class); $path = str_replace('\\', '/', $class);
$path = array_values(array_filter(explode("/", $path))); $path = array_values(array_filter(explode("/", $path)));

@ -1,46 +1,51 @@
server { server {
index index.php index.html; index index.php index.html;
error_log /var/log/nginx/error.log; error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
root /application; root /application;
# rewrite api # rewrite api
rewrite ^/api(/.*)$ /index.php?api=$1; rewrite ^/api(/.*)$ /index.php?api=$1;
# deny access to .gitignore / .htaccess # deny access to .gitignore / .htaccess
location ~ /\. { location ~ /\. {
rewrite ^(.*)$ /index.php?site=$1; rewrite ^(.*)$ /index.php?site=$1;
}
# deny access to docker-compose.yml
location /docker-compose.yml {
rewrite ^(.*)$ /index.php?site=$1;
}
# deny access to specific directories
location ~ ^/(files/uploaded|adminPanel|fileControlPanel|docker|core|test)/.*$ {
rewrite ^(.*)$ /index.php?site=$1;
}
# caching
location ~ ^/(static|js|css)/.*$ {
add_header "Cache-Control" "max-age=0; must-revalidate";
} }
# deny access to docker-compose.yml # try to find the specified file
location /docker-compose.yml { location / {
rewrite ^(.*)$ /index.php?site=$1; try_files $uri $uri @redirectToIndex;
} }
# deny access to specific directories # redirect to index.php
location ~ ^/(files/uploaded|adminPanel|fileControlPanel|docker|core)/.*$ { location @redirectToIndex {
rewrite ^(.*)$ /index.php?site=$1; rewrite ^(.*)$ /index.php?site=$1;
} }
# try to find the specified file # serve .php files
location / { location ~ \.php$ {
try_files $uri $uri @redirectToIndex; try_files $uri =404;
} fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php:9000;
# redirect to index.php fastcgi_index index.php;
location @redirectToIndex { include fastcgi_params;
rewrite ^(.*)$ /index.php?site=$1; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
} fastcgi_param PATH_INFO $fastcgi_path_info;
}
# serve .php files
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
} }

@ -4,10 +4,11 @@ include_once 'core/core.php';
include_once 'core/datetime.php'; include_once 'core/datetime.php';
include_once 'core/constants.php'; include_once 'core/constants.php';
if (is_file("MAINTENANCE")) { define("WEBROOT", realpath("."));
if (is_file("MAINTENANCE") && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
http_response_code(503); http_response_code(503);
$currentDir = dirname(__FILE__); serveStatic(WEBROOT, "/static/maintenance.html");
serveStatic($currentDir, "/static/maintenance.html");
die(); die();
} }
@ -34,10 +35,10 @@ if(isset($_GET["api"]) && is_string($_GET["api"])) {
} else { } else {
$apiFunction = $_GET["api"]; $apiFunction = $_GET["api"];
if(empty($apiFunction)) { if(empty($apiFunction)) {
header("403 Forbidden"); http_response_code(403);
$response = ""; $response = "";
} else if(!preg_match("/[a-zA-Z]+(\/[a-zA-Z]+)*/", $apiFunction)) { } else if(!preg_match("/[a-zA-Z]+(\/[a-zA-Z]+)*/", $apiFunction)) {
header("400 Bad Request"); http_response_code(400);
$response = createError("Invalid Method"); $response = createError("Invalid Method");
} else { } else {
$apiFunction = array_filter(array_map('ucfirst', explode("/", $apiFunction))); $apiFunction = array_filter(array_map('ucfirst', explode("/", $apiFunction)));
@ -52,13 +53,13 @@ if(isset($_GET["api"]) && is_string($_GET["api"])) {
try { try {
$file = getClassPath($parentClass); $file = getClassPath($parentClass);
if(!file_exists($file) || !class_exists($parentClass) || !class_exists($apiClass)) { if(!file_exists($file) || !class_exists($parentClass) || !class_exists($apiClass)) {
header("404 Not Found"); http_response_code(404);
$response = createError("Not found"); $response = createError("Not found");
} else { } else {
$parentClass = new ReflectionClass($parentClass); $parentClass = new ReflectionClass($parentClass);
$apiClass = new ReflectionClass($apiClass); $apiClass = new ReflectionClass($apiClass);
if(!$apiClass->isSubclassOf(Request::class) || !$apiClass->isInstantiable()) { if(!$apiClass->isSubclassOf(Request::class) || !$apiClass->isInstantiable()) {
header("400 Bad Request"); http_response_code(400);
$response = createError("Invalid Method"); $response = createError("Invalid Method");
} else { } else {
$request = $apiClass->newInstanceArgs(array($user, true)); $request = $apiClass->newInstanceArgs(array($user, true));

@ -123,8 +123,8 @@ $(document).ready(function () {
if (!res.success) { if (!res.success) {
showAlert("danger", res.msg); showAlert("danger", res.msg);
} else { } else {
showAlert("success", "Account successfully created. You may now login.");
$("input").val(""); $("input").val("");
document.location = "/login?success=" + encodeURIComponent("Account successfully created. You may now login.");
} }
}); });
} }
@ -182,6 +182,8 @@ $(document).ready(function () {
} else { } else {
showAlert("success", "Your password was successfully changed. You may now login."); showAlert("success", "Your password was successfully changed. You may now login.");
$("input:not([id='siteKey'])").val(""); $("input:not([id='siteKey'])").val("");
btn.hide();
$("#backToLogin").show();
} }
}); });
} }

143
test/AesStream.test.php Normal file

@ -0,0 +1,143 @@
<?php
use Objects\AesStream;
class AesStreamTest extends PHPUnit\Framework\TestCase {
static string $TEMP_FILE;
public static function setUpBeforeClass(): void {
AesStreamTest::$TEMP_FILE = tempnam(sys_get_temp_dir(), 'aesTest');
}
public static function tearDownAfterClass(): void {
unlink(AesStreamTest::$TEMP_FILE);
}
public function testConstructorInvalidKey1() {
$this->expectExceptionMessage("Invalid Key Size");
$this->expectException(\Exception::class);
new AesStream("", "");
}
public function testConstructorInvalidKey2() {
$this->expectExceptionMessage("Invalid Key Size");
$this->expectException(\Exception::class);
new AesStream(str_repeat("A",15), "");
}
public function testConstructorInvalidKey3() {
$this->expectExceptionMessage("Invalid Key Size");
$this->expectException(\Exception::class);
new AesStream(str_repeat("A",33), "");
}
public function testConstructorInvalidIV1() {
$this->expectExceptionMessage("Invalid IV Size");
$this->expectException(\Exception::class);
new AesStream(str_repeat("A",32), str_repeat("B", 17));
}
public function testConstructorValid() {
$key = str_repeat("A",32);
$iv = str_repeat("B", 16);
$aesStream = new AesStream($key, $iv);
$this->assertInstanceOf(AesStream::class, $aesStream);
$this->assertEquals($key, $aesStream->getKey());
$this->assertEquals($iv, $aesStream->getIV());
$this->assertEquals("aes-256-ctr", $aesStream->getCipherMode());
}
private function getOutput(string $chunk, string &$data) {
$data .= $chunk;
}
public function testEncrypt() {
$key = str_repeat("A", 32);
$iv = str_repeat("B", 16);
$aesStream = new AesStream($key, $iv);
$data = [
"43" => "8c", # small block test 1 (1 byte)
"abcd" => "6424", # small block test 2 (2 byte)
"a37c599429cfdefde6546ad6d7082a" => "6c9539264abc8cae39308dbc86e768", # small block test 3 (15 byte)
"43b3504077482bd9bf8c3c08ad3c937f" => "8c5a30f2143b798a60e8db62fcd3d1f7", # one block (16 byte)
"9b241a3d7e9f03f6e66a8fa0cba3221008eda86f465e3fbfb0f3a4d3527cffb7"
=> "54cd7a8f1dec51a5390e68ca9a4c60986aaafadd42b6960a09deedfa7f2cf1c3" # two blocks (16 byte)
];
foreach ($data as $pt => $ct) {
$output = "";
file_put_contents(AesStreamTest::$TEMP_FILE, hex2bin($pt));
$aesStream->setInputFile(AesStreamTest::$TEMP_FILE);
$aesStream->setOutput(function($chunk) use (&$output) { $this->getOutput($chunk, $output); });
$aesStream->start();
$this->assertEquals($ct, bin2hex($output), $ct . " != " . bin2hex($output));
}
}
private function openssl(AesStream $aesStream) {
// check if openssl util produce the same output
$cmd = ["/usr/bin/openssl", $aesStream->getCipherMode(), "-K", bin2hex($aesStream->getKey()), "-iv", bin2hex($aesStream->getIV()), "-in", AesStreamTest::$TEMP_FILE];
$proc = proc_open($cmd, [1 => ["pipe", "w"]], $pipes);
$this->assertTrue(is_resource($proc));
$this->assertTrue(is_resource($pipes[1]));
$output = stream_get_contents($pipes[1]);
proc_close($proc);
return $output;
}
private function testEncryptDecrypt($key, $iv, $inputData) {
$aesStream = new AesStream($key, $iv);
$inputSize = strlen($inputData);
file_put_contents(AesStreamTest::$TEMP_FILE, $inputData);
$output = "";
$aesStream->setInputFile(AesStreamTest::$TEMP_FILE);
$aesStream->setOutput(function($chunk) use (&$output) { $this->getOutput($chunk, $output); });
$aesStream->start();
$this->assertEquals($inputSize, strlen($output));
$this->assertNotEquals($inputData, $output);
// check if openssl util produce the same output
$this->assertEquals($this->openssl($aesStream), $output);
file_put_contents(AesStreamTest::$TEMP_FILE, $output);
$output = "";
$aesStream->setInputFile(AesStreamTest::$TEMP_FILE);
$aesStream->setOutput(function($chunk) use (&$output) { $this->getOutput($chunk, $output); });
$aesStream->start();
$this->assertEquals($inputData, $output);
// check if openssl util produce the same output
$this->assertEquals($this->openssl($aesStream), $output);
}
public function testEncryptDecryptRandom() {
$chunkSize = 65536;
$key = random_bytes(32);
$iv = random_bytes(16);
$inputSize = 10 * $chunkSize;
$inputData = random_bytes($inputSize);
$this->testEncryptDecrypt($key, $iv, $inputData);
}
public function testEncryptDecryptLargeIV() {
$chunkSize = 65536;
$key = random_bytes(32);
$iv = hex2bin(str_repeat("FF", 16));
$inputSize = 10 * $chunkSize;
$inputData = random_bytes($inputSize);
$this->testEncryptDecrypt($key, $iv, $inputData);
}
public function testEncryptDecryptZeroIV() {
$chunkSize = 65536;
$key = random_bytes(32);
$iv = hex2bin(str_repeat("00", 16));
$inputSize = 10 * $chunkSize;
$inputData = random_bytes($inputSize);
$this->testEncryptDecrypt($key, $iv, $inputData);
}
}

108
test/Parameter.test.php Normal file

@ -0,0 +1,108 @@
<?php
use Api\Parameter\ArrayType;
use Api\Parameter\StringType;
use Api\Parameter\Parameter;
class ParameterTest extends \PHPUnit\Framework\TestCase {
public function testStringType() {
// test various string sizes
$unlimited = new StringType("test_unlimited");
$this->assertTrue($unlimited->parseParam(str_repeat("A", 1024)));
$empty = new StringType("test_empty", 0);
$this->assertTrue($empty->parseParam(""));
$this->assertTrue($empty->parseParam("A"));
$one = new StringType("test_one", 1);
$this->assertTrue($one->parseParam(""));
$this->assertTrue($one->parseParam("A"));
$this->assertFalse($one->parseParam("AB"));
$randomSize = rand(1, 64);
$random = new StringType("test_empty", $randomSize);
$data = str_repeat("A", $randomSize);
$this->assertTrue($random->parseParam(""));
$this->assertTrue($random->parseParam("A"));
$this->assertTrue($random->parseParam($data));
$this->assertEquals($data, $random->value);
// test data types
$this->assertFalse($random->parseParam(null));
$this->assertFalse($random->parseParam(1));
$this->assertFalse($random->parseParam(2.5));
$this->assertFalse($random->parseParam(true));
$this->assertFalse($random->parseParam(false));
$this->assertFalse($random->parseParam(["key" => 1]));
}
public function testArrayType() {
// int array type
$arrayType = new ArrayType("int_array", Parameter::TYPE_INT);
$this->assertTrue($arrayType->parseParam([1,2,3]));
$this->assertTrue($arrayType->parseParam([1]));
$this->assertTrue($arrayType->parseParam(["1"]));
$this->assertTrue($arrayType->parseParam([1.0]));
$this->assertTrue($arrayType->parseParam([]));
$this->assertTrue($arrayType->parseParam(["1.0"]));
$this->assertFalse($arrayType->parseParam([1.2]));
$this->assertFalse($arrayType->parseParam(["1.5"]));
$this->assertFalse($arrayType->parseParam([true]));
$this->assertFalse($arrayType->parseParam(1));
// optional single value
$arrayType = new ArrayType("int_array_single", Parameter::TYPE_INT, true);
$this->assertTrue($arrayType->parseParam(1));
// mixed values
$arrayType = new ArrayType("mixed_array", Parameter::TYPE_MIXED);
$this->assertTrue($arrayType->parseParam([1, 2.5, "test", false]));
}
public function testParseType() {
// int
$this->assertEquals(Parameter::TYPE_INT, Parameter::parseType(1));
$this->assertEquals(Parameter::TYPE_INT, Parameter::parseType(1.0));
$this->assertEquals(Parameter::TYPE_INT, Parameter::parseType("1"));
$this->assertEquals(Parameter::TYPE_INT, Parameter::parseType("1.0"));
// array
$this->assertEquals(Parameter::TYPE_ARRAY, Parameter::parseType([1, true]));
// float
$this->assertEquals(Parameter::TYPE_FLOAT, Parameter::parseType(1.5));
$this->assertEquals(Parameter::TYPE_FLOAT, Parameter::parseType(1.234e2));
$this->assertEquals(Parameter::TYPE_FLOAT, Parameter::parseType("1.75"));
// boolean
$this->assertEquals(Parameter::TYPE_BOOLEAN, Parameter::parseType(true));
$this->assertEquals(Parameter::TYPE_BOOLEAN, Parameter::parseType(false));
$this->assertEquals(Parameter::TYPE_BOOLEAN, Parameter::parseType("true"));
$this->assertEquals(Parameter::TYPE_BOOLEAN, Parameter::parseType("false"));
// date
$this->assertEquals(Parameter::TYPE_DATE, Parameter::parseType("2021-11-13"));
$this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("2021-13-11")); # invalid date
// time
$this->assertEquals(Parameter::TYPE_TIME, Parameter::parseType("10:11:12"));
$this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("25:11:12")); # invalid time
// datetime
$this->assertEquals(Parameter::TYPE_DATE_TIME, Parameter::parseType("2021-11-13 10:11:12"));
$this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("2021-13-13 10:11:12")); # invalid date
$this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("2021-13-11 10:61:12")); # invalid time
// email
$this->assertEquals(Parameter::TYPE_EMAIL, Parameter::parseType("a@b.com"));
$this->assertEquals(Parameter::TYPE_EMAIL, Parameter::parseType("test.123@example.com"));
$this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("@example.com")); # invalid email
$this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("test@")); # invalid email
// string, everything else
$this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("test"));
}
}

192
test/Request.test.php Normal file

@ -0,0 +1,192 @@
<?php
use Api\Request;
use Configuration\Configuration;
use Objects\User;
function __new_header_impl(string $line) {
if (preg_match("/^HTTP\/([0-9.]+) (\d+) (.*)$/", $line, $m)) {
RequestTest::$SENT_STATUS_CODE = intval($m[2]);
return;
}
$key = $line;
$value = "";
$index = strpos($key, ": ");
if ($index !== false) {
$key = substr($line, 0, $index);
$value = substr($line, $index + 2);
}
RequestTest::$SENT_HEADERS[$key] = $value;
}
function __new_http_response_code_impl(int $code) {
RequestTest::$SENT_STATUS_CODE = $code;
}
function __new_die_impl($content) {
RequestTest::$SENT_CONTENT = $content;
}
class RequestTest extends \PHPUnit\Framework\TestCase {
const FUNCTION_OVERRIDES = ["header", "http_response_code"];
static User $USER;
static User $USER_LOGGED_IN;
static ?string $SENT_CONTENT;
static array $SENT_HEADERS;
static ?int $SENT_STATUS_CODE;
public static function setUpBeforeClass(): void {
$config = new Configuration();
RequestTest::$USER = new User($config);
RequestTest::$USER_LOGGED_IN = new User($config);
if (!RequestTest::$USER->getSQL() || !RequestTest::$USER->getSQL()->isConnected()) {
throw new Exception("Could not establish database connection");
} else {
RequestTest::$USER->setLanguage(\Objects\Language::DEFAULT_LANGUAGE());
}
if (!function_exists("runkit7_function_rename") || !function_exists("runkit7_function_remove")) {
throw new Exception("Request Unit Test requires runkit7 extension");
}
if (ini_get("runkit.internal_override") !== "1") {
throw new Exception("Request Unit Test requires runkit7 with internal_override enabled to function properly");
}
foreach (self::FUNCTION_OVERRIDES as $functionName) {
runkit7_function_rename($functionName, "__orig_${functionName}_impl");
runkit7_function_rename("__new_${functionName}_impl", $functionName);
}
}
public static function tearDownAfterClass(): void {
RequestTest::$USER->getSQL()->close();
foreach (self::FUNCTION_OVERRIDES as $functionName) {
runkit7_function_remove($functionName);
runkit7_function_rename("__orig_${functionName}_impl", $functionName);
}
}
private function simulateRequest(Request $request, string $method, array $get = [], array $post = [], array $headers = []): bool {
if (!is_cli()) {
self::throwException(new \Exception("Cannot simulate request outside cli"));
}
$_SERVER = [];
$_SERVER["REQUEST_METHOD"] = $method;
self::$SENT_HEADERS = [];
self::$SENT_STATUS_CODE = null;
self::$SENT_CONTENT = null;
foreach ($headers as $key => $value) {
$key = "HTTP_" . preg_replace("/\s/", "_", strtoupper($key));
$_SERVER[$key] = $value;
}
$_GET = $get;
$_POST = $post;
return $request->execute();
}
public function testAllMethods() {
// all methods allowed
$allMethodsAllowed = new RequestAllMethods(RequestTest::$USER, true);
$this->assertTrue($this->simulateRequest($allMethodsAllowed, "GET"), $allMethodsAllowed->getLastError());
$this->assertTrue($this->simulateRequest($allMethodsAllowed, "POST"), $allMethodsAllowed->getLastError());
$this->assertFalse($this->simulateRequest($allMethodsAllowed, "PUT"), $allMethodsAllowed->getLastError());
$this->assertFalse($this->simulateRequest($allMethodsAllowed, "DELETE"), $allMethodsAllowed->getLastError());
$this->assertTrue($this->simulateRequest($allMethodsAllowed, "OPTIONS"), $allMethodsAllowed->getLastError());
$this->assertEquals(204, self::$SENT_STATUS_CODE);
$this->assertEquals(["Allow" => "OPTIONS, GET, POST"], self::$SENT_HEADERS);
}
public function testOnlyPost() {
// only post allowed
$onlyPostAllowed = new RequestOnlyPost(RequestTest::$USER, true);
$this->assertFalse($this->simulateRequest($onlyPostAllowed, "GET"));
$this->assertEquals("This method is not allowed", $onlyPostAllowed->getLastError(), $onlyPostAllowed->getLastError());
$this->assertEquals(405, self::$SENT_STATUS_CODE);
$this->assertTrue($this->simulateRequest($onlyPostAllowed, "POST"), $onlyPostAllowed->getLastError());
$this->assertTrue($this->simulateRequest($onlyPostAllowed, "OPTIONS"), $onlyPostAllowed->getLastError());
$this->assertEquals(204, self::$SENT_STATUS_CODE);
$this->assertEquals(["Allow" => "OPTIONS, POST"], self::$SENT_HEADERS);
}
public function testPrivate() {
// private method
$privateExternal = new RequestPrivate(RequestTest::$USER, true);
$this->assertFalse($this->simulateRequest($privateExternal, "GET"));
$this->assertEquals("This function is private.", $privateExternal->getLastError());
$this->assertEquals(403, self::$SENT_STATUS_CODE);
$privateInternal = new RequestPrivate(RequestTest::$USER, false);
$this->assertTrue($privateInternal->execute());
}
public function testDisabled() {
// disabled method
$disabledMethod = new RequestDisabled(RequestTest::$USER, true);
$this->assertFalse($this->simulateRequest($disabledMethod, "GET"));
$this->assertEquals("This function is currently disabled.", $disabledMethod->getLastError(), $disabledMethod->getLastError());
$this->assertEquals(503, self::$SENT_STATUS_CODE);
}
public function testLoginRequired() {
$loginRequired = new RequestLoginRequired(RequestTest::$USER, true);
$this->assertFalse($this->simulateRequest($loginRequired, "GET"));
$this->assertEquals("You are not logged in.", $loginRequired->getLastError(), $loginRequired->getLastError());
$this->assertEquals(401, self::$SENT_STATUS_CODE);
}
}
abstract class TestRequest extends Request {
public function __construct(User $user, bool $externalCall = false, $params = []) {
parent::__construct($user, $externalCall, $params);
}
protected function _die(string $data = ""): bool {
__new_die_impl($data);
return true;
}
}
class RequestAllMethods extends TestRequest {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, []);
}
}
class RequestOnlyPost extends TestRequest {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, []);
$this->forbidMethod("GET");
}
}
class RequestPrivate extends TestRequest {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, []);
$this->isPublic = false;
}
}
class RequestDisabled extends TestRequest {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, []);
$this->isDisabled = true;
}
}
class RequestLoginRequired extends TestRequest {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, []);
$this->loginRequired = true;
}
}