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!
foreach ($tables as $table => $uids) {
$success = $sql->delete($table)
->where(new CondIn("uid", $uids))
->where(new CondIn(new Column("uid"), $uids))
->execute();
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) {
$columns = [];
@ -349,6 +361,7 @@ function printTable(array $head, array $body) {
$columns[$key] = strlen($key);
}
$maxWidth = getConsoleWidth();
foreach ($body as $row) {
foreach ($head as $key) {
$value = $row[$key] ?? "";
@ -364,14 +377,61 @@ function printTable(array $head, array $body) {
printLine();
foreach ($body as $row) {
$line = 0;
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();
}
}
// 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) {
$user = getUser() or die();
@ -459,7 +519,60 @@ function onRoutes(array $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) {
@ -507,6 +620,9 @@ switch ($command) {
case 'mail':
onMail($argv);
break;
case 'settings':
onSettings($argv);
break;
default:
printLine("Unknown command '$command'");
printLine();

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

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

@ -61,8 +61,9 @@ namespace Api\Permission {
return true;
}
if (!$this->user->isLoggedIn() || empty(array_intersect($groups, array_keys($this->user->getGroups())))) {
header('HTTP 1.1 401 Unauthorized');
$userGroups = $this->user->getGroups();
if (empty($userGroups) || empty(array_intersect($groups, array_keys($this->user->getGroups())))) {
http_response_code(401);
return $this->createError("Permission denied.");
}
}
@ -197,7 +198,7 @@ namespace Api\Permission {
if ($this->success) {
$res = $sql->delete("ApiPermission")
->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();
$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 {
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 {
$this->params = array_merge([], $this->defaultParams);
$this->success = false;
@ -98,7 +114,7 @@ class Request {
$values = array_merge($values, $jsonData);
} else {
$this->lastError = 'Invalid request body.';
header('HTTP 1.1 400 Bad Request');
http_response_code(400);
return false;
}
}
@ -106,39 +122,48 @@ class Request {
if ($this->isDisabled) {
$this->lastError = "This function is currently disabled.";
http_response_code(503);
return false;
}
if ($this->externalCall && !$this->isPublic) {
$this->lastError = 'This function is private.';
header('HTTP 1.1 403 Forbidden');
http_response_code(403);
return false;
}
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
if (!in_array($_SERVER['REQUEST_METHOD'], $this->allowedMethods)) {
$this->lastError = 'This method is not allowed';
header('HTTP 1.1 405 Method Not Allowed');
http_response_code(405);
return false;
}
$apiKeyAuthorized = false;
// Logged in or api key authorized?
if ($this->loginRequired) {
if (isset($_SERVER["HTTP_AUTHORIZATION"]) && $this->apiKeyAllowed) {
if (!$this->user->isLoggedIn() && $this->apiKeyAllowed) {
if (isset($_SERVER["HTTP_AUTHORIZATION"])) {
$authHeader = $_SERVER["HTTP_AUTHORIZATION"];
if (startsWith($authHeader, "Bearer ")) {
$apiKey = substr($authHeader, strlen("Bearer "));
$apiKeyAuthorized = $this->user->authorize($apiKey);
}
}
}
// Logged in or api key authorized?
if ($this->loginRequired) {
if (!$this->user->isLoggedIn() && !$apiKeyAuthorized) {
$this->lastError = 'You are not logged in.';
header('HTTP 1.1 401 Unauthorized');
http_response_code(401);
return false;
}
}
@ -149,7 +174,7 @@ class Request {
// 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) {
$this->lastError = "CSRF-Token mismatch";
header('HTTP 1.1 403 Forbidden');
http_response_code(403);
return false;
}
}
@ -235,9 +260,6 @@ class Request {
}
protected function disableOutputBuffer() {
header('X-Accel-Buffering: no');
header("Cache-Control: no-transform, no-store, max-age=0");
ob_implicit_flush(true);
$levels = ob_get_level();
for ( $i = 0; $i < $levels; $i ++ ) {
@ -245,4 +267,84 @@ class Request {
}
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")
->from("Settings")
->where(new CondBool("readonly"))
->where(new CondIn("name", $keys))
->where(new CondIn(new Column("name"), $keys))
->limit(1)
->execute();
@ -158,7 +158,7 @@ namespace Api\Settings {
private function deleteKeys(array $keys) {
$sql = $this->user->getSQL();
$res = $sql->delete("Settings")
->where(new CondIn("name", $keys))
->where(new CondIn(new Column("name"), $keys))
->execute();
$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 {
protected function userExists(?string $username, ?string $email = null) {
protected function userExists(?string $username, ?string $email = null): bool {
$conditions = array();
if ($username) {
@ -42,8 +42,8 @@ namespace Api {
return $this->success;
}
protected function checkPasswordRequirements($password, $confirmPassword) {
if(strcmp($password, $confirmPassword) !== 0) {
protected function checkPasswordRequirements($password, $confirmPassword): bool {
if ((($password === null) !== ($confirmPassword === null)) || strcmp($password, $confirmPassword) !== 0) {
return $this->createError("The given passwords do not match");
} else if(strlen($password) < 6) {
return $this->createError("The password should be at least 6 characters long");
@ -91,7 +91,8 @@ namespace Api {
protected function getUser($id) {
$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")
->from("User")
->leftJoin("UserGroup", "User.uid", "UserGroup.user_id")
@ -105,24 +106,6 @@ namespace Api {
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) {
$this->user->getSQL()
->update("UserToken")
@ -142,6 +125,14 @@ namespace Api {
$this->lastError = $sql->getLastError();
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\StringType;
use Api\Template\Render;
use Api\UserAPI;
use Api\VerifyCaptcha;
use DateTime;
use Driver\SQL\Column\Column;
use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\CondBool;
use Driver\SQL\Condition\CondIn;
use Driver\SQL\Condition\CondNot;
use Driver\SQL\Expression\JsonArrayAgg;
use ImagickException;
use Objects\User;
class Create extends UserAPI {
@ -239,10 +235,10 @@ namespace Api\User {
$this->success = ($res !== NULL);
$this->lastError = $sql->getLastError();
if ($this->success) {
$ids = array();
foreach($res as $row) $ids[] = $row["uid"];
return $ids;
if ($this->success && is_array($res)) {
return array_map(function ($row) {
return intval($row["uid"]);
}, $res);
}
return false;
@ -274,11 +270,12 @@ namespace Api\User {
$sql = $this->user->getSQL();
$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")
->leftJoin("UserGroup", "User.uid", "UserGroup.user_id")
->leftJoin("Group", "Group.uid", "UserGroup.group_id")
->where(new CondIn("User.uid", $userIds))
->where(new CondIn(new Column("User.uid"), $userIds))
->execute();
$this->success = ($res !== FALSE);
@ -291,15 +288,29 @@ namespace Api\User {
$groupId = intval($row["groupId"]);
$groupName = $row["groupName"];
$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])) {
$this->result["users"][$userId] = array(
$user = array(
"uid" => $userId,
"name" => $row["name"],
"fullName" => $row["fullName"],
"profilePicture" => $row["profilePicture"],
"email" => $row["email"],
"registered_at" => $row["registered_at"],
"confirmed" => $sql->parseBool($row["confirmed"]),
"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)) {
@ -323,6 +334,7 @@ namespace Api\User {
parent::__construct($user, $externalCall, array(
'id' => new Parameter('id', Parameter::TYPE_INT)
));
$this->loginRequired = true;
}
public function execute($values = array()): bool {
@ -331,30 +343,79 @@ namespace Api\User {
}
$sql = $this->user->getSQL();
$id = $this->getParam("id");
$user = $this->getUser($id);
$userId = $this->getParam("id");
$user = $this->getUser($userId);
if ($this->success) {
if (empty($user)) {
return $this->createError("User not found");
} else {
$this->result["user"] = array(
"uid" => $user[0]["userId"],
$queriedUser = array(
"uid" => $userId,
"name" => $user[0]["name"],
"fullName" => $user[0]["fullName"],
"email" => $user[0]["email"],
"registered_at" => $user[0]["registered_at"],
"last_online" => $user[0]["last_online"],
"profilePicture" => $user[0]["profilePicture"],
"confirmed" => $sql->parseBool($user["0"]["confirmed"]),
"groups" => array()
"groups" => array(),
);
foreach($user as $row) {
if (!is_null($row["groupId"])) {
$this->result["user"]["groups"][$row["groupId"]] = array(
$queriedUser["groups"][$row["groupId"]] = array(
"name" => $row["groupName"],
"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;
}
$messageBody = $this->getMessageTemplate("message_accept_invite");
if ($messageBody === false) {
return false;
}
// Create user
$id = $this->insertUser($username, $email, "", false);
if (!$this->success) {
@ -437,7 +493,8 @@ namespace Api\User {
// Create Token
$token = generateRandomString(36);
$valid_until = (new DateTime())->modify("+7 day");
$validDays = 7;
$valid_until = (new DateTime())->modify("+$validDays day");
$sql = $this->user->getSQL();
$res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until"))
->addRow($id, $token, "invite", $valid_until)
@ -449,20 +506,24 @@ namespace Api\User {
if ($this->success) {
$settings = $this->user->getConfiguration()->getSettings();
$baseUrl = htmlspecialchars($settings->getBaseUrl());
$siteName = htmlspecialchars($settings->getSiteName());
$baseUrl = $settings->getBaseUrl();
$siteName = $settings->getSiteName();
$replacements = array(
$req = new Render($this->user);
$this->success = $req->execute([
"file" => "mail/accept_invite.twig",
"parameters" => [
"link" => "$baseUrl/acceptInvite?token=$token",
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => htmlspecialchars($username)
);
foreach($replacements as $key => $value) {
$messageBody = str_replace("{{{$key}}}", $value, $messageBody);
}
"username" => $username,
"valid_time" => $this->formatDuration($validDays, "day")
]
]);
$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,
@ -471,6 +532,7 @@ namespace Api\User {
));
$this->lastError = $request->getLastError();
}
if (!$this->success) {
$this->lastError = "The invitation was created but the confirmation email could not be sent. " .
@ -607,7 +669,7 @@ namespace Api\User {
public function __construct($user, $externalCall = false) {
parent::__construct($user, $externalCall, array(
'username' => new StringType('username', 32),
'username' => new StringType('username'),
'password' => new StringType('password'),
'stayLoggedIn' => new Parameter('stayLoggedIn', Parameter::TYPE_BOOLEAN, true, true)
));
@ -641,7 +703,8 @@ namespace Api\User {
$sql = $this->user->getSQL();
$res = $sql->select("User.uid", "User.password", "User.confirmed")
->from("User")
->where(new Compare("User.name", $username))
->where(new Compare("User.name", $username), new Compare("User.email", $username))
->limit(1)
->execute();
$this->success = ($res !== FALSE);
@ -753,35 +816,38 @@ namespace Api\User {
return false;
}
$messageBody = $this->getMessageTemplate("message_confirm_email");
if ($messageBody === false) {
return false;
}
$this->userId = $this->insertUser($username, $email, $password, false);
if (!$this->success) {
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);
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();
$baseUrl = htmlspecialchars($settings->getBaseUrl());
$siteName = htmlspecialchars($settings->getSiteName());
if ($this->success) {
$replacements = array(
$baseUrl = $settings->getBaseUrl();
$siteName = $settings->getSiteName();
$req = new Render($this->user);
$this->success = $req->execute([
"file" => "mail/confirm_email.twig",
"parameters" => [
"link" => "$baseUrl/confirmEmail?token=$this->token",
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => htmlspecialchars($username)
);
foreach($replacements as $key => $value) {
$messageBody = str_replace("{{{$key}}}", $value, $messageBody);
}
"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,
@ -862,6 +928,7 @@ namespace Api\User {
parent::__construct($user, $externalCall, array(
'id' => new Parameter('id', Parameter::TYPE_INT),
'username' => new StringType('username', 32, true, NULL),
'fullName' => new StringType('fullName', 64, true, NULL),
'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL),
'password' => new StringType('password', -1, true, NULL),
'groups' => new Parameter('groups', Parameter::TYPE_ARRAY, true, NULL),
@ -886,6 +953,7 @@ namespace Api\User {
}
$username = $this->getParam("username");
$fullName = $this->getParam("fullName");
$email = $this->getParam("email");
$password = $this->getParam("password");
$groups = $this->getParam("groups");
@ -913,6 +981,7 @@ namespace Api\User {
// Check for duplicate username, email
$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;
if($usernameChanged || $emailChanged) {
if (!$this->userExists($usernameChanged ? $username : NULL, $emailChanged ? $email : NULL)) {
@ -924,6 +993,7 @@ namespace Api\User {
$query = $sql->update("User");
if ($usernameChanged) $query->set("name", $username);
if ($fullNameChanged) $query->set("fullName", $fullName);
if ($emailChanged) $query->set("email", $email);
if (!is_null($password)) $query->set("password", $this->hashPassword($password));
@ -1028,37 +1098,37 @@ namespace Api\User {
}
}
$messageBody = $this->getMessageTemplate("message_reset_password");
if ($messageBody === false) {
return false;
}
$email = $this->getParam("email");
$user = $this->findUser($email);
if ($user === false) {
if ($this->success === false) {
return false;
}
if ($user !== null) {
$validHours = 1;
$token = generateRandomString(36);
if (!$this->insertToken($user["uid"], $token, "password_reset", 1)) {
if (!$this->insertToken($user["uid"], $token, "password_reset", $validHours)) {
return false;
}
$baseUrl = htmlspecialchars($settings->getBaseUrl());
$siteName = htmlspecialchars($settings->getSiteName());
$baseUrl = $settings->getBaseUrl();
$siteName = $settings->getSiteName();
$replacements = array(
$req = new Render($this->user);
$this->success = $req->execute([
"file" => "mail/reset_password.twig",
"parameters" => [
"link" => "$baseUrl/resetPassword?token=$token",
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => htmlspecialchars($user["name"])
);
foreach($replacements as $key => $value) {
$messageBody = str_replace("{{{$key}}}", $value, $messageBody);
}
"username" => $user["name"],
"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,
@ -1067,11 +1137,12 @@ namespace Api\User {
));
$this->lastError = $request->getLastError();
}
}
return $this->success;
}
private function findUser($email) {
private function findUser($email): ?array {
$sql = $this->user->getSQL();
$res = $sql->select("User.uid", "User.name")
->from("User")
@ -1082,14 +1153,12 @@ namespace Api\User {
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
if (empty($res)) {
return null;
} else {
if (!empty($res)) {
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");
$sql = $this->user->getSQL();
$res = $sql->select("User.uid", "User.name", "UserToken.token", "UserToken.token_type", "UserToken.used")
@ -1157,28 +1221,39 @@ namespace Api\User {
}))
);
$validHours = 48;
if (!$token) {
// no token generated yet, let's generate one
$token = generateRandomString(36);
if (!$this->insertToken($userId, $token, "email_confirm", 48)) {
if (!$this->insertToken($userId, $token, "email_confirm", $validHours)) {
return false;
}
} else {
$sql->update("UserToken")
->set("valid_until", (new DateTime())->modify("+$validHours hour"))
->where(new Compare("token", $token))
->execute();
}
$username = $res[0]["name"];
$baseUrl = htmlspecialchars($settings->getBaseUrl());
$siteName = htmlspecialchars($settings->getSiteName());
$replacements = array(
$baseUrl = $settings->getBaseUrl();
$siteName = $settings->getSiteName();
$req = new Render($this->user);
$this->success = $req->execute([
"file" => "mail/confirm_email.twig",
"parameters" => [
"link" => "$baseUrl/confirmEmail?token=$token",
"site_name" => $siteName,
"base_url" => $baseUrl,
"username" => htmlspecialchars($username)
);
foreach($replacements as $key => $value) {
$messageBody = str_replace("{{{$key}}}", $value, $messageBody);
}
"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,
@ -1187,6 +1262,8 @@ namespace Api\User {
));
$this->lastError = $request->getLastError();
}
return $this->success;
}
}
@ -1203,7 +1280,7 @@ namespace Api\User {
$this->csrfTokenRequired = false;
}
private function updateUser($uid, $password) {
private function updateUser($uid, $password): bool {
$sql = $this->user->getSQL();
$res = $sql->update("User")
->set("password", $this->hashPassword($password))
@ -1254,7 +1331,10 @@ namespace Api\User {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, array(
'username' => new StringType('username', 32, true, NULL),
'fullName' => new StringType('fullName', 64, 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->csrfTokenRequired = true;
@ -1267,14 +1347,17 @@ namespace Api\User {
}
$newUsername = $this->getParam("username");
$oldPassword = $this->getParam("oldPassword");
$newPassword = $this->getParam("password");
$newPasswordConfirm = $this->getParam("confirmPassword");
$newFullName = $this->getParam("fullName");
if ($newUsername === null && $newPassword === null) {
return $this->createError("You must either provide an updated username or password");
if ($newUsername === null && $newPassword === null && $newPasswordConfirm === null && $newFullName === null) {
return $this->createError("You must either provide an updated username, fullName or password");
}
$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 (!$this->checkUsernameRequirements($newUsername) || $this->userExists($newUsername)) {
return false;
@ -1283,10 +1366,29 @@ namespace Api\User {
}
}
if ($newPassword !== null) { // TODO: confirm password?
if (!$this->checkPasswordRequirements($newPassword, $newPassword)) {
if ($newFullName !== null) {
$query->set("fullName", $newFullName);
}
if ($newPassword !== null || $newPasswordConfirm !== null) {
if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) {
return false;
} 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));
}
}
@ -1296,4 +1398,152 @@ namespace Api\User {
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) {
$result = $req->getResult()["settings"];
$this->siteName = $result["site_name"] ?? $this->siteName;
$this->baseUrl = $result["base_url"] ?? $this->baseUrl;
$this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed;
$this->installationComplete = $result["installation_completed"] ?? $this->installationComplete;
$this->jwtSecret = $result["jwt_secret"] ?? $this->jwtSecret;

@ -1,74 +1,62 @@
<?php
namespace Documents {
use Documents\Account\AccountBody;
use Documents\Account\AccountHead;
use Elements\Document;
use Objects\User;
namespace Documents;
class Account extends Document {
public function __construct(User $user, ?string $view) {
parent::__construct($user, AccountHead::class, AccountBody::class, $view);
}
}
}
namespace Documents\Account {
use Elements\Head;
use Elements\Link;
use Elements\Script;
use Elements\SimpleBody;
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 {
public function __construct($document) {
parent::__construct($document);
}
protected function getContent(): string {
$view = $this->getDocument()->getView();
if ($view === null) {
return "The page you does not exist or is no longer valid. <a href='/'>Return to start page</a>";
}
return $view->getCode();
use Elements\TemplateDocument;
use Objects\User;
class Account extends TemplateDocument {
public function __construct(User $user, ?string $template) {
parent::__construct($user, $template);
$this->enableCSP();
}
private function createError(string $message) {
$this->parameters["view"]["success"] = false;
$this->parameters["view"]["message"] = $message;
}
protected function loadParameters() {
$this->parameters["view"] = ["success" => true];
if ($this->getTemplateName() === "account/reset_password.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"], "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");
}
}
}
}

@ -1,51 +1,15 @@
<?php
namespace Documents {
namespace Documents;
use Documents\Admin\AdminHead;
use Elements\Document;
use Objects\User;
use Views\Admin\AdminDashboardBody;
use Views\Admin\LoginBody;
use Elements\TemplateDocument;
use Objects\User;
class Admin extends Document {
public function __construct(User $user, ?string $view = NULL) {
$body = $user->isLoggedIn() ? AdminDashboardBody::class : LoginBody::class;
parent::__construct($user, AdminHead::class, $body, $view);
}
}
}
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";
}
class Admin extends TemplateDocument {
public function __construct(User $user) {
$template = $user->isLoggedIn() ? "admin.twig" : "redirect.twig";
$params = $user->isLoggedIn() ? [] : ["url" => "/login"];
parent::__construct($user, $template, $params);
$this->enableCSP();
}
}

@ -1,64 +1,18 @@
<?php
namespace Documents {
namespace Documents;
use Documents\Document404\Body404;
use Documents\Document404\Head404;
use Elements\Document;
use Elements\TemplateDocument;
use Objects\User;
class Document404 extends Document {
public function __construct($user, ?string $view = NULL) {
parent::__construct($user, Head404::class, Body404::class, $view);
}
}
}
class Document404 extends TemplateDocument {
namespace Documents\Document404 {
use Elements\Head;
use Elements\SimpleBody;
use Views\View404;
class Head404 extends Head {
public function __construct($document) {
parent::__construct($document);
public function __construct(User $user) {
parent::__construct($user, "404.twig");
}
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() {
public function loadParameters() {
parent::loadParameters();
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\InstallHead;
use Elements\Document;
use Elements\HtmlDocument;
class Install extends Document {
class Install extends HtmlDocument {
public function __construct($user) {
parent::__construct($user, InstallHead::class, InstallBody::class);
$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;
}
public function addValues(string $value) {
$this->values[] = $value;
}
public function getValues(): array { return $this->values; }
}

@ -4,8 +4,20 @@ namespace Driver\SQL\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);
$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 {
private string $column;
private $expression;
private $needle;
private $haystack;
public function __construct(string $column, $expression) {
$this->column = $column;
$this->expression = $expression;
public function __construct($needle, $haystack) {
$this->needle = $needle;
$this->haystack = $haystack;
}
public function getColumn(): string { return $this->column; }
public function getExpression() { return $this->expression; }
public function getNeedle() { return $this->needle; }
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;
use Driver\SQL\Condition\Condition;
class Sum extends Expression {
private $value;

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

@ -20,6 +20,7 @@ use Driver\SQL\Expression\CurrentTimeStamp;
use Driver\SQL\Expression\DateAdd;
use Driver\SQL\Expression\DateSub;
use Driver\SQL\Expression\Expression;
use Driver\SQL\Expression\JsonArrayAgg;
use Driver\SQL\Query\CreateProcedure;
use Driver\SQL\Query\CreateTrigger;
use Driver\SQL\Query\Insert;
@ -219,7 +220,7 @@ class PostgreSQL extends SQL {
} else if($column instanceof SerialColumn) {
return "SERIAL";
} else if($column instanceof IntColumn) {
return "INTEGER";
return $column->getType();
} else if($column instanceof DateTimeColumn) {
return "TIMESTAMP";
} else if($column instanceof EnumColumn) {
@ -439,6 +440,10 @@ class PostgreSQL extends SQL {
return "$lhs $operator $rhs";
} else if ($exp instanceof CurrentTimeStamp) {
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 {
return parent::createExpression($exp, $params);
}

@ -3,15 +3,19 @@
namespace Driver\SQL\Query;
use Driver\SQL\Column\Column;
use Driver\SQL\Column\EnumColumn;
use Driver\SQL\Constraint\Constraint;
use Driver\SQL\Constraint\ForeignKey;
use Driver\SQL\Constraint\PrimaryKey;
use Driver\SQL\MySQL;
use Driver\SQL\PostgreSQL;
use Driver\SQL\SQL;
class AlterTable extends Query {
private string $table;
private string $action;
private $data;
private ?Column $column;
private ?Constraint $constraint;
@ -59,6 +63,13 @@ class AlterTable extends Query {
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 getColumn(): ?Column { return $this->column; }
public function getConstraint(): ?Constraint { return $this->constraint; }
@ -82,6 +93,15 @@ class AlterTable extends Query {
$query .= $this->sql->columnName($column->getName());
} else {
// 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);
}
} else if ($constraint) {

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

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

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

@ -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 $rel;
private string $href;
private ?string $nonce;
function __construct($rel, $href, $type = "") {
$this->href = $href;
$this->type = $type;
$this->rel = $rel;
$this->nonce = null;
}
function getCode(): string {
$type = (empty($this->type) ? "" : " type=\"$this->type\"");
return "<link rel=\"$this->rel\" href=\"$this->href\"$type/>";
$attributes = ["rel" => $this->rel, "href" => $this->href];
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 BOOTSTRAP = "/js/bootstrap.bundle.min.js";
const ACCOUNT = "/js/account.js";
const SECLAB = "/js/seclab.min.js";
const FONTAWESOME = "/js/fontawesome-all.min.js";
private string $type;
private string $content;
private string $src;
private ?string $nonce;
function __construct($type, $src, $content = "") {
$this->src = $src;
$this->type = $type;
$this->content = $content;
$this->nonce = null;
}
function getCode(): string {
$src = (empty($this->src) ? "" : " src=\"$this->src\"");
return "<script type=\"$this->type\"$src>$this->content</script>";
$attributes = ["type" => $this->type];
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
/**
* PHPMailer Exception class.
* PHP Version 5.5.
@ -9,7 +10,7 @@
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2017 Marcus Bointon
* @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@ -34,6 +35,6 @@ class Exception extends \Exception
*/
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
/**
* PHPMailer - PHP email creation and transport class.
* PHP Version 5.5.
@ -9,7 +10,7 @@
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2015 Marcus Bointon
* @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@ -122,7 +123,7 @@ class OAuth
*/
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()) {
$this->oauthToken = $this->getToken();
}

File diff suppressed because it is too large Load Diff

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

@ -1,4 +1,5 @@
<?php
/**
* PHPMailer RFC821 SMTP email transport class.
* PHP Version 5.5.
@ -9,7 +10,7 @@
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2019 Marcus Bointon
* @copyright 2012 - 2020 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@ -34,7 +35,7 @@ class SMTP
*
* @var string
*/
const VERSION = '6.1.4';
const VERSION = '6.5.1';
/**
* SMTP line break constant.
@ -185,6 +186,7 @@ class SMTP
'Amazon_SES' => '/[\d]{3} Ok (.*)/',
'SendGrid' => '/[\d]{3} Ok: queued as (.*)/',
'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 = [])
{
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');
}
// Clear errors to avoid confusion
//Clear errors to avoid confusion
$this->setError('');
// Make sure we are __not__ connected
//Make sure we are __not__ connected
if ($this->connected()) {
// Already connected, generate error
//Already connected, generate error
$this->setError('Already connected to a server');
return false;
@ -329,18 +325,66 @@ class SMTP
if (empty($port)) {
$port = self::DEFAULT_PORT;
}
// Connect to the SMTP server
//Connect to the SMTP server
$this->edebug(
"Connection: opening to $host:$port, timeout=$timeout, options=" .
(count($options) > 0 ? var_export($options, true) : 'array()'),
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;
$errstr = '';
if ($streamok) {
$socket_context = stream_context_create($options);
set_error_handler([$this, 'errorHandler']);
$this->smtp_conn = stream_socket_client(
$connection = stream_socket_client(
$host . ':' . $port,
$errno,
$errstr,
@ -348,7 +392,6 @@ class SMTP
STREAM_CLIENT_CONNECT,
$socket_context
);
restore_error_handler();
} else {
//Fall back to fsockopen which should work in more places, but is missing some features
$this->edebug(
@ -356,17 +399,18 @@ class SMTP
self::DEBUG_CONNECTION
);
set_error_handler([$this, 'errorHandler']);
$this->smtp_conn = fsockopen(
$connection = fsockopen(
$host,
$port,
$errno,
$errstr,
$timeout
);
restore_error_handler();
}
// Verify we connected properly
if (!is_resource($this->smtp_conn)) {
restore_error_handler();
//Verify we connected properly
if (!is_resource($connection)) {
$this->setError(
'Failed to connect to server',
'',
@ -381,22 +425,19 @@ class SMTP
return false;
}
$this->edebug('Connection: opened', self::DEBUG_CONNECTION);
// SMTP server can take longer to respond, give longer timeout for first read
// Windows does not have support for this timeout function
//SMTP server can take longer to respond, give longer timeout for first read
//Windows does not have support for this timeout function
if (strpos(PHP_OS, 'WIN') !== 0) {
$max = (int) ini_get('max_execution_time');
// Don't bother if unlimited
if (0 !== $max && $timeout > $max) {
$max = (int)ini_get('max_execution_time');
//Don't bother if unlimited, or if set_time_limit is disabled
if (0 !== $max && $timeout > $max && strpos(ini_get('disable_functions'), 'set_time_limit') === false) {
@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;
}
// Begin encrypted connection
//Begin encrypted connection
set_error_handler([$this, 'errorHandler']);
$crypto_ok = stream_socket_enable_crypto(
$this->smtp_conn,
@ -458,11 +499,11 @@ class SMTP
}
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)) {
$this->setError('Authentication is not allowed at this stage');
// 'at this stage' means that auth may be allowed after the stage changes
// e.g. after STARTTLS
//'at this stage' means that auth may be allowed after the stage changes
//e.g. after STARTTLS
return false;
}
@ -506,12 +547,15 @@ class SMTP
}
switch ($authtype) {
case 'PLAIN':
// Start authentication
//Start authentication
if (!$this->sendCommand('AUTH', 'AUTH PLAIN', 334)) {
return false;
}
// Send encoded username and password
if (!$this->sendCommand(
//Send encoded username and password
if (
//Format from https://tools.ietf.org/html/rfc4616#section-2
//We skip the first field (it's forgery), so the string starts with a null byte
!$this->sendCommand(
'User & Password',
base64_encode("\0" . $username . "\0" . $password),
235
@ -521,7 +565,7 @@ class SMTP
}
break;
case 'LOGIN':
// Start authentication
//Start authentication
if (!$this->sendCommand('AUTH', 'AUTH LOGIN', 334)) {
return false;
}
@ -533,17 +577,17 @@ class SMTP
}
break;
case 'CRAM-MD5':
// Start authentication
//Start authentication
if (!$this->sendCommand('AUTH CRAM-MD5', 'AUTH CRAM-MD5', 334)) {
return false;
}
// Get the challenge
//Get the challenge
$challenge = base64_decode(substr($this->last_reply, 4));
// Build the response
//Build the response
$response = $username . ' ' . $this->hmac($challenge, $password);
// send encoded credentials
//send encoded credentials
return $this->sendCommand('Username', base64_encode($response), 235);
case 'XOAUTH2':
//The OAuth instance must be set up prior to requesting auth.
@ -552,7 +596,7 @@ class SMTP
}
$oauth = $OAuth->getOauth64();
// Start authentication
//Start authentication
if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) {
return false;
}
@ -582,15 +626,15 @@ class SMTP
return hash_hmac('md5', $data, $key);
}
// The following borrowed from
// http://php.net/manual/en/function.mhash.php#27225
//The following borrowed from
//http://php.net/manual/en/function.mhash.php#27225
// RFC 2104 HMAC implementation for php.
// Creates an md5 HMAC.
// Eliminates the need to install mhash to compute a HMAC
// by Lance Rushing
//RFC 2104 HMAC implementation for php.
//Creates an md5 HMAC.
//Eliminates the need to install mhash to compute a HMAC
//by Lance Rushing
$bytelen = 64; // byte length for md5
$bytelen = 64; //byte length for md5
if (strlen($key) > $bytelen) {
$key = pack('H*', md5($key));
}
@ -613,7 +657,7 @@ class SMTP
if (is_resource($this->smtp_conn)) {
$sock_status = stream_get_meta_data($this->smtp_conn);
if ($sock_status['eof']) {
// The socket is valid but we are not connected
//The socket is valid but we are not connected
$this->edebug(
'SMTP NOTICE: EOF caught while checking if connected',
self::DEBUG_CLIENT
@ -623,7 +667,7 @@ class SMTP
return false;
}
return true; // everything looks good
return true; //everything looks good
}
return false;
@ -641,7 +685,7 @@ class SMTP
$this->server_caps = null;
$this->helo_rply = null;
if (is_resource($this->smtp_conn)) {
// close the connection and cleanup
//Close the connection and cleanup
fclose($this->smtp_conn);
$this->smtp_conn = null; //Makes for cleaner serialization
$this->edebug('Connection: closed', self::DEBUG_CONNECTION);
@ -651,7 +695,7 @@ class SMTP
/**
* Send an SMTP DATA command.
* 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
* on a single line followed by a <CRLF> with the message headers
* 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.
*/
// Normalize line breaks before exploding
//Normalize line breaks before exploding
$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
@ -722,7 +766,8 @@ class SMTP
//Send the lines to the server
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] === '.') {
$line_out = '.' . $line_out;
}
@ -756,7 +801,16 @@ class SMTP
public function hello($host = '')
{
//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->last_reply = $this->get_lines();
// Fetch SMTP code and possible error code explanation
//Fetch SMTP code and possible error code explanation
$matches = [];
if (preg_match('/^([\d]{3})[ -](?:([\d]\\.[\d]\\.[\d]{1,2}) )?/', $this->last_reply, $matches)) {
$code = (int) $matches[1];
$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(
"/{$code}[ -]" .
($code_ex ? str_replace('.', '\\.', $code_ex) . ' ' : '') . '/m',
@ -959,7 +1013,7 @@ class SMTP
$this->last_reply
);
} 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_ex = null;
$detail = substr($this->last_reply, 4);
@ -1058,8 +1112,10 @@ class SMTP
{
//If SMTP transcripts are left enabled, or debug output is posted online
//it can leak credentials, so hide credentials in all but lowest level
if (self::DEBUG_LOWLEVEL > $this->do_debug &&
in_array($command, ['User & Password', 'Username', 'Password'], true)) {
if (
self::DEBUG_LOWLEVEL > $this->do_debug &&
in_array($command, ['User & Password', 'Username', 'Password'], true)
) {
$this->edebug('CLIENT -> SERVER: [credentials hidden]', self::DEBUG_CLIENT);
} else {
$this->edebug('CLIENT -> SERVER: ' . $data, self::DEBUG_CLIENT);
@ -1113,7 +1169,7 @@ class SMTP
if (!$this->server_caps) {
$this->setError('No HELO/EHLO was sent');
return;
return null;
}
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');
return;
return null;
}
return $this->server_caps[$name];
@ -1152,7 +1208,7 @@ class SMTP
*/
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)) {
return '';
}
@ -1166,33 +1222,61 @@ class SMTP
$selW = null;
while (is_resource($this->smtp_conn) && !feof($this->smtp_conn)) {
//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(
'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
);
break;
}
//Deliberate noise suppression - errors are handled afterwards
$str = @fgets($this->smtp_conn, self::MAX_REPLY_LENGTH);
$this->edebug('SMTP INBOUND: "' . trim($str) . '"', self::DEBUG_LOWLEVEL);
$data .= $str;
// 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.
// String array access is a significant micro-optimisation over strlen
//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.
//String array access is a significant micro-optimisation over strlen
if (!isset($str[3]) || $str[3] === ' ' || $str[3] === "\r" || $str[3] === "\n") {
break;
}
// Timed-out? Log and break
//Timed-out? Log and break
$info = stream_get_meta_data($this->smtp_conn);
if ($info['timed_out']) {
$this->edebug(
'SMTP -> get_lines(): timed-out (' . $this->Timeout . ' sec)',
'SMTP -> get_lines(): stream timed-out (' . $this->Timeout . ' sec)',
self::DEBUG_LOWLEVEL
);
break;
}
// Now check if reads took too long
//Now check if reads took too long
if ($endtime && time() > $endtime) {
$this->edebug(
'SMTP -> get_lines(): timelimit reached (' .
@ -1344,6 +1428,7 @@ class SMTP
} else {
$this->last_smtp_transaction_id = false;
foreach ($this->smtp_transaction_id_patterns as $smtp_transaction_id_pattern) {
$matches = [];
if (preg_match($smtp_transaction_id_pattern, $reply, $matches)) {
$this->last_smtp_transaction_id = trim($matches[1]);
break;

@ -26,16 +26,16 @@ namespace External\ZipStream {
private $content = '';
private $fileHandle = false;
private $lastModificationTimestamp;
private $crc32 = null;
private $fileSize = 0;
private $compressedSize = 0;
protected $fileSize = 0;
protected $compressedSize = 0;
private $offset = 0;
private $bitField = 0;
private $useCompression = true;
protected $useCompression = true;
private $deflateState = null;
//check for duplications //currently not used
private $sha256;
protected $crc32 = null;
protected $sha256;
public const BIT_NO_SIZE_IN_HEADER = 0b0000000000001000;
public const BIT_UTF8_NAMES = 0b0000100000000000;
@ -45,12 +45,17 @@ namespace External\ZipStream {
$this->lastModificationTimestamp = time();
$this->crc32 = hash('crc32b', '', true);
$this->compressedSize = 0;
$this->fileSize = 0;
$this->bitField = 0;
$this->bitField |= self::BIT_NO_SIZE_IN_HEADER;
$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) {
@ -68,13 +73,6 @@ namespace External\ZipStream {
$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() {
return $this->name;
}
@ -101,14 +99,14 @@ namespace External\ZipStream {
($day);
}
public function readLocalFileHeader() {
public function readLocalFileHeader(bool $zip64 = false) {
if (!$this->useCompression) {
$this->compressedSize = $this->fileSize;
}
$header = "";
$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
if ($this->useCompression) {
$header .= "\x08\x00"; //compression Method - deflate
@ -117,6 +115,16 @@ namespace External\ZipStream {
}
$header .= pack("v", $this->unixTimeToDosTime($this->lastModificationTimestamp)); //dos time
$header .= pack("v", $this->unixTimeToDosDate($this->lastModificationTimestamp)); //dos date
if ($zip64) {
if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
$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 {
if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
$header .= pack("V", 0); //crc32
$header .= pack("V", 0); //compressed Size
@ -126,19 +134,40 @@ namespace External\ZipStream {
$header .= pack("V", $this->compressedSize); //compressed Size
$header .= pack("V", $this->fileSize); //uncompressed Size
}
}
$header .= pack("v", strlen($this->name)); //filename
if ($zip64) {
$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;
}
public function readDataDescriptor() {
public function readDataDescriptor(bool $zip64 = false) {
if (!$this->useCompression) {
$this->compressedSize = $this->fileSize;
}
$data = "";
$data .= "\x50\x4b\x07\x08";
$data .= strrev($this->crc32);
$data .= pack("V", $this->compressedSize); //compressed Size
$data .= pack("V", $this->fileSize); //uncompressed Size
$data .= $zip64 ? pack("P", $this->compressedSize) : pack("V", $this->compressedSize); //compressed Size
$data .= $zip64 ? pack("P", $this->fileSize) : pack("V", $this->fileSize); //uncompressed Size
return $data;
}
@ -156,21 +185,28 @@ namespace External\ZipStream {
return $ret;
}
public function readFileData() {
protected function compress($block) {
$ret = null;
if ($this->useCompression) {
$block = $this->readFileDataImp();
if ($this->deflateState !== null) {
if ($block !== null) {
if (!empty($block)) {
$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);
}
return $ret;
}
public function readFileData() {
$ret = null;
if ($this->useCompression) {
$block = $this->readFileDataImp();
$ret = $this->compress($block);
} else {
$ret = $this->readFileDataImp();
}
@ -181,27 +217,61 @@ namespace External\ZipStream {
$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 .= "\x50\x4b\x01\x02";
$header .= "\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 .= $zip64 ? "\x2d\x00" : "\x14\x00"; //version 2.0 and MS-DOS compatible
$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->unixTimeToDosDate($this->lastModificationTimestamp)); //dos date
$header .= strrev($this->crc32);
$header .= pack("V", $this->compressedSize); //compressed Size
$header .= pack("V", $this->fileSize); //uncompressed Size
$header .= $compressedSize; //compressed Size
$header .= $fileSize; //uncompressed Size
$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"; //disk num start
$header .= "\x00\x00"; //int file attr
$header .= "\x00\x00\x00\x00"; //ext file attr
$header .= pack("V", $this->offset); //relative offset
$header .= $offset; //relative offset
$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;
}

@ -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 {
class ZipStream {
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->zip64 = $zip64;
}
public function saveFile($file) {
@ -40,32 +42,66 @@ namespace External\ZipStream {
}
}
$file->setOffset($this->writer->offset());
$this->writer->write($file->readLocalFileHeader());
$this->writer->write($file->readLocalFileHeader($this->zip64));
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;
$file->closeHandle();
return true;
}
// Write end of central directory record
public function close() {
$size = 0;
$offset = $this->writer->offset();
foreach ($this->files as $file) {
$size += $this->writer->write($file->readCentralDirectoryHeader());
$size += $this->writer->write($file->readCentralDirectoryHeader($this->zip64));
}
$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 .= "\x00\x00"; //number of disks
$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 .= pack("v", count($this->files)); //total number of entries in the central directory
$data .= 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 .= "\x0\x0"; //comment length
$data .= $this->zip64 ? "\xFF\xFF" : 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
$data .= $this->zip64 ? "\xFF\xFF\xFF\xFF" : pack("V", $size); // size of the central directory
$data .= $this->zip64 ? "\xFF\xFF\xFF\xFF" : pack("V", $offset); //offset of start of central directory with respect to the starting disk number
$data .= "\x00\x00"; //comment length
$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;
}
public function setOutput($callback) {
public function setOutput(callable $callback) {
$this->callback = $callback;
}
@ -36,51 +36,13 @@ class AesStream {
$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 {
if (!$this->inputFile) {
return false;
}
$blockSize = 16;
$bitStrength = strlen($this->key) * 8;
$aesMode = "AES-$bitStrength-ECB";
$aesMode = $this->getCipherMode();
$outputHandle = null;
$inputHandle = fopen($this->inputFile, "rb");
@ -91,25 +53,30 @@ class AesStream {
if ($this->outputFile !== null) {
$outputHandle = fopen($this->outputFile, "wb");
if (!$outputHandle) {
fclose($inputHandle);
return false;
}
}
$counter = 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);
}
set_time_limit(0);
$ivCounter = $this->add($this->iv, $counter + 1);
$encrypted = substr(openssl_encrypt($ivCounter, $aesMode, $this->key, OPENSSL_RAW_DATA), 0, $blockSize);
$encrypted = $this->xor($encrypted, $block);
if (is_callable($this->callback)) {
$ivCounter = $this->iv;
$modulo = \gmp_init("0x1" . str_repeat("00", $blockSize), 16);
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);
}
@ -123,4 +90,17 @@ class AesStream {
if ($outputHandle) fclose($outputHandle);
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 getShortCode() { return substr($this->langCode, 0, 2); }
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

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

@ -17,7 +17,9 @@ class User extends ApiObject {
private ?Session $session;
private int $uid;
private string $username;
private string $fullName;
private ?string $email;
private ?string $profilePicture;
private Language $language;
private array $groups;
@ -55,6 +57,7 @@ class User extends ApiObject {
public function getId(): int { return $this->uid; }
public function isLoggedIn(): bool { return $this->loggedIn; }
public function getUsername(): string { return $this->username; }
public function getFullName(): string { return $this->fullName; }
public function getEmail(): ?string { return $this->email; }
public function getSQL(): ?SQL { return $this->sql; }
public function getLanguage(): Language { return $this->language; }
@ -63,6 +66,7 @@ class User extends ApiObject {
public function getConfiguration(): Configuration { return $this->configuration; }
public function getGroups(): array { return $this->groups; }
public function hasGroup(int $group): bool { return isset($this->groups[$group]); }
public function getProfilePicture() : ?string { return $this->profilePicture; }
public function __debugInfo(): array {
$debugInfo = array(
@ -83,6 +87,8 @@ class User extends ApiObject {
return array(
'uid' => $this->uid,
'name' => $this->username,
'fullName' => $this->fullName,
'profilePicture' => $this->profilePicture,
'email' => $this->email,
'groups' => $this->groups,
'language' => $this->language->jsonSerialize(),
@ -99,8 +105,10 @@ class User extends ApiObject {
$this->uid = 0;
$this->username = '';
$this->email = '';
$this->groups = [];
$this->loggedIn = false;
$this->session = null;
$this->profilePicture = null;
}
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
* @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",
"Session.data", "Session.stay_logged_in", "Session.csrf_token", "Group.uid as groupId", "Group.name as groupName")
->from("User")
@ -162,7 +170,10 @@ class User extends ApiObject {
$csrfToken = $row["csrf_token"];
$this->username = $row['name'];
$this->email = $row["email"];
$this->fullName = $row["fullName"];
$this->uid = $userId;
$this->profilePicture = $row["profilePicture"];
$this->session = new Session($this, $sessionId, $csrfToken);
$this->session->setData(json_decode($row["data"] ?? '{}'));
$this->session->stayLoggedIn($this->sql->parseBool(["stay_logged_in"]));
@ -183,16 +194,14 @@ class User extends ApiObject {
}
private function parseCookies() {
if(isset($_COOKIE['session'])
&& is_string($_COOKIE['session'])
&& !empty($_COOKIE['session'])) {
if(isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
try {
$token = $_COOKIE['session'];
$settings = $this->configuration->getSettings();
$decoded = (array)JWT::decode($token, $settings->getJwtSecret());
if(!is_null($decoded)) {
$userId = (isset($decoded['userId']) ? $decoded['userId'] : NULL);
$sessionId = (isset($decoded['sessionId']) ? $decoded['sessionId'] : NULL);
$userId = ($decoded['userId'] ?? NULL);
$sessionId = ($decoded['sessionId'] ?? NULL);
if(!is_null($userId) && !is_null($sessionId)) {
$this->readData($userId, $sessionId);
}
@ -226,7 +235,8 @@ class User extends ApiObject {
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",
"Group.uid as groupId", "Group.name as groupName")
->from("ApiKey")
@ -240,8 +250,8 @@ class User extends ApiObject {
->execute();
$success = ($res !== FALSE);
if($success) {
if(empty($res)) {
if ($success) {
if (empty($res) || !is_array($res)) {
$success = false;
} else {
$row = $res[0];
@ -251,7 +261,9 @@ class User extends ApiObject {
$this->uid = $row['uid'];
$this->username = $row['name'];
$this->fullName = $row["fullName"];
$this->email = $row['email'];
$this->profilePicture = $row["profilePicture"];
if(!is_null($row['langId'])) {
$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,15 +1,19 @@
<?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_register(function($class) {
if (!class_exists($class)) {
$full_path = WEBROOT . "/" . getClassPath($class);
if (file_exists($full_path)) {
include_once $full_path;
} else {
include_once getClassPath($class, false);
}
}
});
function is_cli(): bool {
@ -24,6 +28,13 @@ function getProtocol(): string {
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 {
$randomString = '';
@ -31,12 +42,14 @@ function generateRandomString($length, $type = "ascii"): string {
$uppercase = strtoupper($lowercase);
$digits = "0123456789";
$hex = $digits . substr($lowercase, 0, 6);
$ascii = $lowercase . $uppercase . $digits;
$ascii = $uppercase . $lowercase . $digits;
if ($length > 0) {
$type = strtolower($type);
if ($type === "hex") {
$charset = $hex;
} else if ($type === "base64") {
$charset = $ascii . "/+";
} else {
$charset = $ascii;
}
@ -136,6 +149,13 @@ function urlId($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 {
$path = str_replace('\\', '/', $class);
$path = array_values(array_filter(explode("/", $path)));

@ -18,10 +18,15 @@ server {
}
# deny access to specific directories
location ~ ^/(files/uploaded|adminPanel|fileControlPanel|docker|core)/.*$ {
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";
}
# try to find the specified file
location / {
try_files $uri $uri @redirectToIndex;

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

@ -123,8 +123,8 @@ $(document).ready(function () {
if (!res.success) {
showAlert("danger", res.msg);
} else {
showAlert("success", "Account successfully created. You may now login.");
$("input").val("");
document.location = "/login?success=" + encodeURIComponent("Account successfully created. You may now login.");
}
});
}
@ -182,6 +182,8 @@ $(document).ready(function () {
} else {
showAlert("success", "Your password was successfully changed. You may now login.");
$("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;
}
}