diff --git a/cli.php b/cli.php index e3878d1..88528be 100644 --- a/cli.php +++ b/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] "); + } 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] "); + } 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 "); + } +} + 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(); diff --git a/core/Api/MailAPI.class.php b/core/Api/MailAPI.class.php index 0b51213..49133df 100644 --- a/core/Api/MailAPI.class.php +++ b/core/Api/MailAPI.class.php @@ -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, "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(); } diff --git a/core/Api/NotificationsAPI.class.php b/core/Api/NotificationsAPI.class.php index 123523b..f16c033 100644 --- a/core/Api/NotificationsAPI.class.php +++ b/core/Api/NotificationsAPI.class.php @@ -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())))) diff --git a/core/Api/PermissionAPI.class.php b/core/Api/PermissionAPI.class.php index 0cdc1d4..77e3798 100644 --- a/core/Api/PermissionAPI.class.php +++ b/core/Api/PermissionAPI.class.php @@ -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); diff --git a/core/Api/Request.class.php b/core/Api/Request.class.php index 41d2c36..50d82ea 100644 --- a/core/Api/Request.class.php +++ b/core/Api/Request.class.php @@ -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()); + } + } } \ No newline at end of file diff --git a/core/Api/SettingsAPI.class.php b/core/Api/SettingsAPI.class.php index 9ddf4c3..be9d313 100644 --- a/core/Api/SettingsAPI.class.php +++ b/core/Api/SettingsAPI.class.php @@ -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); diff --git a/core/Api/TemplateAPI.class.php b/core/Api/TemplateAPI.class.php new file mode 100644 index 0000000..6a10687 --- /dev/null +++ b/core/Api/TemplateAPI.class.php @@ -0,0 +1,78 @@ +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; + } + + } + +} \ No newline at end of file diff --git a/core/Api/UserAPI.class.php b/core/Api/UserAPI.class.php index 9dcdf5b..56cea54 100644 --- a/core/Api/UserAPI.class.php +++ b/core/Api/UserAPI.class.php @@ -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,29 +506,34 @@ 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( - "link" => "$baseUrl/acceptInvite?token=$token", - "site_name" => $siteName, - "base_url" => $baseUrl, - "username" => htmlspecialchars($username) - ); + $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" => $username, + "valid_time" => $this->formatDuration($validDays, "day") + ] + ]); + $this->lastError = $req->getLastError(); - foreach($replacements as $key => $value) { - $messageBody = str_replace("{{{$key}}}", $value, $messageBody); + if ($this->success) { + $messageBody = $req->getResult()["html"]; + $request = new \Api\Mail\Send($this->user); + $this->success = $request->execute(array( + "to" => $email, + "subject" => "[$siteName] Account Invitation", + "body" => $messageBody + )); + + $this->lastError = $request->getLastError(); } - $request = new \Api\Mail\Send($this->user); - $this->success = $request->execute(array( - "to" => $email, - "subject" => "[$siteName] Account Invitation", - "body" => $messageBody - )); - - $this->lastError = $request->getLastError(); - if (!$this->success) { $this->lastError = "The invitation was created but the confirmation email could not be sent. " . "Please contact the server administration. Reason: " . $this->lastError; @@ -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,50 +1098,51 @@ 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( - "link" => "$baseUrl/resetPassword?token=$token", - "site_name" => $siteName, - "base_url" => $baseUrl, - "username" => htmlspecialchars($user["name"]) - ); + $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" => $user["name"], + "valid_time" => $this->formatDuration($validHours, "hour") + ] + ]); + $this->lastError = $req->getLastError(); - foreach($replacements as $key => $value) { - $messageBody = str_replace("{{{$key}}}", $value, $messageBody); + if ($this->success) { + $messageBody = $req->getResult()["html"]; + $request = new \Api\Mail\Send($this->user); + $this->success = $request->execute(array( + "to" => $email, + "subject" => "[$siteName] Password Reset", + "body" => $messageBody + )); + $this->lastError = $request->getLastError(); } - - $request = new \Api\Mail\Send($this->user); - $this->success = $request->execute(array( - "to" => $email, - "subject" => "[$siteName] Password Reset", - "body" => $messageBody - )); - $this->lastError = $request->getLastError(); } return $this->success; } - 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,36 +1221,49 @@ 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( - "link" => "$baseUrl/confirmEmail?token=$token", - "site_name" => $siteName, - "base_url" => $baseUrl, - "username" => htmlspecialchars($username) - ); + $baseUrl = $settings->getBaseUrl(); + $siteName = $settings->getSiteName(); - foreach($replacements as $key => $value) { - $messageBody = str_replace("{{{$key}}}", $value, $messageBody); + $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" => $username, + "valid_time" => $this->formatDuration($validHours, "hour") + ] + ]); + $this->lastError = $req->getLastError(); + + if ($this->success) { + $messageBody = $req->getResult()["html"]; + $request = new \Api\Mail\Send($this->user); + $this->success = $request->execute(array( + "to" => $email, + "subject" => "[$siteName] E-Mail Confirmation", + "body" => $messageBody + )); + + $this->lastError = $request->getLastError(); } - $request = new \Api\Mail\Send($this->user); - $this->success = $request->execute(array( - "to" => $email, - "subject" => "[$siteName] E-Mail Confirmation", - "body" => $messageBody - )); - - $this->lastError = $request->getLastError(); return $this->success; } } @@ -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; + } + } } \ No newline at end of file diff --git a/core/Configuration/Settings.class.php b/core/Configuration/Settings.class.php index 2d45a5d..6fb5ccc 100644 --- a/core/Configuration/Settings.class.php +++ b/core/Configuration/Settings.class.php @@ -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; diff --git a/core/Documents/Account.class.php b/core/Documents/Account.class.php index b8dc6d6..70d35d2 100644 --- a/core/Documents/Account.class.php +++ b/core/Documents/Account.class.php @@ -1,74 +1,62 @@ 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 Account extends TemplateDocument { + public function __construct(User $user, ?string $template) { + parent::__construct($user, $template); + $this->enableCSP(); } - class AccountBody extends SimpleBody { + private function createError(string $message) { + $this->parameters["view"]["success"] = false; + $this->parameters["view"]["message"] = $message; + } - 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. Return to start page"; + 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"); } - - return $view->getCode(); } } } \ No newline at end of file diff --git a/core/Documents/Admin.class.php b/core/Documents/Admin.class.php index 8056640..e1740d4 100644 --- a/core/Documents/Admin.class.php +++ b/core/Documents/Admin.class.php @@ -1,51 +1,15 @@ 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(); } } \ No newline at end of file diff --git a/core/Documents/Document404.class.php b/core/Documents/Document404.class.php index 376039b..b491ced 100644 --- a/core/Documents/Document404.class.php +++ b/core/Documents/Document404.class.php @@ -1,64 +1,18 @@ 'viewport', 'content' => 'width=device-width, initial-scale=1.0'), - array('name' => 'format-detection', 'content' => 'telephone=yes'), - array('charset' => 'utf-8'), - array("http-equiv" => 'expires', 'content' => '0'), - array("name" => 'robots', 'content' => 'noarchive'), - ); - } - - protected function initRawFields(): array { - return array(); - } - - protected function initTitle(): string { - return "WebBase - Not Found"; - } - } - - class Body404 extends SimpleBody { - - public function __construct($document) { - parent::__construct($document); - } - - public function loadView() { - http_response_code(404); - } - - protected function getContent(): string { - return $this->load(View404::class); - } +class Document404 extends TemplateDocument { + + public function __construct(User $user) { + parent::__construct($user, "404.twig"); + } + + public function loadParameters() { + parent::loadParameters(); + http_response_code(404); } } diff --git a/core/Documents/Install.class.php b/core/Documents/Install.class.php index cc6fdf6..37ac920 100644 --- a/core/Documents/Install.class.php +++ b/core/Documents/Install.class.php @@ -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; diff --git a/core/Driver/SQL/Column/BigIntColumn.php b/core/Driver/SQL/Column/BigIntColumn.php new file mode 100644 index 0000000..d63fec2 --- /dev/null +++ b/core/Driver/SQL/Column/BigIntColumn.php @@ -0,0 +1,13 @@ +type = "BIGINT"; + } +} \ No newline at end of file diff --git a/core/Driver/SQL/Column/EnumColumn.class.php b/core/Driver/SQL/Column/EnumColumn.class.php index f76c3cc..55fd99d 100644 --- a/core/Driver/SQL/Column/EnumColumn.class.php +++ b/core/Driver/SQL/Column/EnumColumn.class.php @@ -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; } } diff --git a/core/Driver/SQL/Column/IntColumn.class.php b/core/Driver/SQL/Column/IntColumn.class.php index d6954e6..df0bf3e 100644 --- a/core/Driver/SQL/Column/IntColumn.class.php +++ b/core/Driver/SQL/Column/IntColumn.class.php @@ -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; + } } diff --git a/core/Driver/SQL/Condition/CondIn.class.php b/core/Driver/SQL/Condition/CondIn.class.php index a77bcf6..4839d84 100644 --- a/core/Driver/SQL/Condition/CondIn.class.php +++ b/core/Driver/SQL/Condition/CondIn.class.php @@ -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; } } \ No newline at end of file diff --git a/core/Driver/SQL/Expression/JsonArrayAgg.class.php b/core/Driver/SQL/Expression/JsonArrayAgg.class.php new file mode 100644 index 0000000..dc2ca57 --- /dev/null +++ b/core/Driver/SQL/Expression/JsonArrayAgg.class.php @@ -0,0 +1,18 @@ +value = $value; + $this->alias = $alias; + } + + public function getValue() { return $this->value; } + public function getAlias(): string { return $this->alias; } + +} \ No newline at end of file diff --git a/core/Driver/SQL/Expression/Sum.class.php b/core/Driver/SQL/Expression/Sum.class.php index 8bbb39f..7b0a185 100644 --- a/core/Driver/SQL/Expression/Sum.class.php +++ b/core/Driver/SQL/Expression/Sum.class.php @@ -2,8 +2,6 @@ namespace Driver\SQL\Expression; -use Driver\SQL\Condition\Condition; - class Sum extends Expression { private $value; diff --git a/core/Driver/SQL/MySQL.class.php b/core/Driver/SQL/MySQL.class.php index 7a8f7a8..2476499 100644 --- a/core/Driver/SQL/MySQL.class.php +++ b/core/Driver/SQL/MySQL.class.php @@ -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); } diff --git a/core/Driver/SQL/PostgreSQL.class.php b/core/Driver/SQL/PostgreSQL.class.php index 4fa67be..afef26c 100644 --- a/core/Driver/SQL/PostgreSQL.class.php +++ b/core/Driver/SQL/PostgreSQL.class.php @@ -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); } diff --git a/core/Driver/SQL/Query/AlterTable.class.php b/core/Driver/SQL/Query/AlterTable.class.php index 15e4841..152a115 100644 --- a/core/Driver/SQL/Query/AlterTable.class.php +++ b/core/Driver/SQL/Query/AlterTable.class.php @@ -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) { diff --git a/core/Driver/SQL/Query/CreateTable.class.php b/core/Driver/SQL/Query/CreateTable.class.php index e58d39a..a3c9e6d 100644 --- a/core/Driver/SQL/Query/CreateTable.class.php +++ b/core/Driver/SQL/Query/CreateTable.class.php @@ -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; } diff --git a/core/Driver/SQL/SQL.class.php b/core/Driver/SQL/SQL.class.php index 508a35e..e39eb08 100644 --- a/core/Driver/SQL/SQL.class.php +++ b/core/Driver/SQL/SQL.class.php @@ -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); diff --git a/core/Elements/Document.class.php b/core/Elements/Document.class.php index 4d6decf..088a530 100644 --- a/core/Elements/Document.class.php +++ b/core/Elements/Document.class.php @@ -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; - } - - $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 getSQL(): ?SQL { + return $this->user->getSQL(); } - public function getRequestedView(): string { - return $this->activeView; + public function getUser(): User { + return $this->user; } - function getCode(): string { + public function getCSPNonce(): ?string { + return $this->cspNonce; + } + public function isCSPEnabled(): bool { + return $this->cspEnabled; + } + + public function enableCSP() { + $this->cspEnabled = true; + $this->cspNonce = generateRandomString(16, "base62"); + } + + public function getCode(): string { if ($this->databaseRequired) { $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(); + 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'"; + } - $html = ""; - $html .= ""; - $html .= $head; - $html .= $body; - $html .= ""; - return $html; + $compiledCSP = implode(";", $csp); + header("Content-Security-Policy: $compiledCSP;"); + } + + return ""; } - } \ No newline at end of file diff --git a/core/Elements/HtmlDocument.class.php b/core/Elements/HtmlDocument.class.php new file mode 100644 index 0000000..5aea669 --- /dev/null +++ b/core/Elements/HtmlDocument.class.php @@ -0,0 +1,79 @@ +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 = ""; + $html .= ""; + $html .= $head; + $html .= $body; + $html .= ""; + return $html; + } + + +} \ No newline at end of file diff --git a/core/Elements/Link.class.php b/core/Elements/Link.class.php index 09cd7fc..b8e67aa 100644 --- a/core/Elements/Link.class.php +++ b/core/Elements/Link.class.php @@ -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 "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 ""; + } + + public function setNonce(string $nonce) { + $this->nonce = $nonce; } } diff --git a/core/Elements/Script.class.php b/core/Elements/Script.class.php index 74a6953..7b97d68 100644 --- a/core/Elements/Script.class.php +++ b/core/Elements/Script.class.php @@ -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 ""; + $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 ""; + } + + public function setNonce(string $nonce) { + $this->nonce = $nonce; } } \ No newline at end of file diff --git a/core/Elements/TemplateDocument.class.php b/core/Elements/TemplateDocument.class.php new file mode 100644 index 0000000..38ca713 --- /dev/null +++ b/core/Elements/TemplateDocument.class.php @@ -0,0 +1,71 @@ +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 "Error rendering twig template: " . htmlspecialchars($e->getMessage()) . ""; + } + } +} \ No newline at end of file diff --git a/core/External/.gitignore b/core/External/.gitignore new file mode 100644 index 0000000..a725465 --- /dev/null +++ b/core/External/.gitignore @@ -0,0 +1 @@ +vendor/ \ No newline at end of file diff --git a/core/External/PHPMailer/Exception.php b/core/External/PHPMailer/Exception.php index 383ff64..82a7cc7 100644 --- a/core/External/PHPMailer/Exception.php +++ b/core/External/PHPMailer/Exception.php @@ -1,4 +1,5 @@ * @author Andy Prevost (codeworxtech) * @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 '' . htmlspecialchars($this->getMessage()) . "
\n"; + return '' . htmlspecialchars($this->getMessage(), ENT_COMPAT | ENT_HTML401) . "
\n"; } } diff --git a/core/External/PHPMailer/OAuth.php b/core/External/PHPMailer/OAuth.php index c1c34c4..7dfa9db 100644 --- a/core/External/PHPMailer/OAuth.php +++ b/core/External/PHPMailer/OAuth.php @@ -1,4 +1,5 @@ * @author Andy Prevost (codeworxtech) * @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(); } diff --git a/core/External/PHPMailer/PHPMailer.php b/core/External/PHPMailer/PHPMailer.php index 86c1a82..b6171f9 100644 --- a/core/External/PHPMailer/PHPMailer.php +++ b/core/External/PHPMailer/PHPMailer.php @@ -1,4 +1,5 @@ * @author Andy Prevost (codeworxtech) * @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 @@ -40,6 +41,7 @@ class PHPMailer const CONTENT_TYPE_MULTIPART_ALTERNATIVE = 'multipart/alternative'; const CONTENT_TYPE_MULTIPART_MIXED = 'multipart/mixed'; const CONTENT_TYPE_MULTIPART_RELATED = 'multipart/related'; + const CONTENT_TYPE_MULTIPART_ENCRYPTED = 'multipart/encrypted'; const ENCODING_7BIT = '7bit'; const ENCODING_8BIT = '8bit'; @@ -64,7 +66,7 @@ class PHPMailer * Options: null (default), 1 = High, 3 = Normal, 5 = low. * When null, the header is not set at all. * - * @var int + * @var int|null */ public $Priority; @@ -102,14 +104,14 @@ class PHPMailer * * @var string */ - public $From = 'root@localhost'; + public $From = ''; /** * The From name of the message. * * @var string */ - public $FromName = 'Root User'; + public $FromName = ''; /** * The envelope sender of the message. @@ -388,11 +390,11 @@ class PHPMailer * SMTP class debug output mode. * Debug output level. * Options: - * * SMTP::DEBUG_OFF: No output - * * SMTP::DEBUG_CLIENT: Client messages - * * SMTP::DEBUG_SERVER: Client and server messages - * * SMTP::DEBUG_CONNECTION: As SERVER plus connection status - * * SMTP::DEBUG_LOWLEVEL: Noisy, low-level data output, rarely needed + * @see SMTP::DEBUG_OFF: No output + * @see SMTP::DEBUG_CLIENT: Client messages + * @see SMTP::DEBUG_SERVER: Client and server messages + * @see SMTP::DEBUG_CONNECTION: As SERVER plus connection status + * @see SMTP::DEBUG_LOWLEVEL: Noisy, low-level data output, rarely needed * * @see SMTP::$do_debug * @@ -427,9 +429,11 @@ class PHPMailer public $Debugoutput = 'echo'; /** - * Whether to keep SMTP connection open after each message. - * If this is set to true then to close the connection - * requires an explicit call to smtpClose(). + * Whether to keep the SMTP connection open after each message. + * If this is set to true then the connection will remain open after a send, + * and closing the connection will require an explicit call to smtpClose(). + * It's a good idea to use this if you are sending multiple messages as it reduces overhead. + * See the mailing list example for how to use it. * * @var bool */ @@ -441,6 +445,8 @@ class PHPMailer * Only supported in `mail` and `sendmail` transports, not in SMTP. * * @var bool + * + * @deprecated 6.0.0 PHPMailer isn't a mailing list manager! */ public $SingleTo = false; @@ -684,7 +690,7 @@ class PHPMailer protected $boundary = []; /** - * The array of available languages. + * The array of available text strings for the current language. * * @var array */ @@ -745,7 +751,7 @@ class PHPMailer * * @var string */ - const VERSION = '6.1.4'; + const VERSION = '6.5.1'; /** * Error severity: message only, continue processing. @@ -769,11 +775,22 @@ class PHPMailer const STOP_CRITICAL = 2; /** - * SMTP RFC standard line ending. + * The SMTP standard CRLF line break. + * If you want to change line break format, change static::$LE, not this. + */ + const CRLF = "\r\n"; + + /** + * "Folding White Space" a white space string used for line folding. + */ + const FWS = ' '; + + /** + * SMTP RFC standard line ending; Carriage Return, Line Feed. * * @var string */ - protected static $LE = "\r\n"; + protected static $LE = self::CRLF; /** * The maximum line length supported by mail(). @@ -848,18 +865,25 @@ class PHPMailer $subject = $this->encodeHeader($this->secureHeader($subject)); } //Calling mail() with null params breaks + $this->edebug('Sending with mail()'); + $this->edebug('Sendmail path: ' . ini_get('sendmail_path')); + $this->edebug("Envelope sender: {$this->Sender}"); + $this->edebug("To: {$to}"); + $this->edebug("Subject: {$subject}"); + $this->edebug("Headers: {$header}"); if (!$this->UseSendmailOptions || null === $params) { $result = @mail($to, $subject, $body, $header); } else { + $this->edebug("Additional params: {$params}"); $result = @mail($to, $subject, $body, $header, $params); } - + $this->edebug('Result: ' . ($result ? 'true' : 'false')); return $result; } /** - * Output debugging info via user-defined method. - * Only generates output if SMTP debug output is enabled (@see SMTP::$do_debug). + * Output debugging info via a user-defined method. + * Only generates output if debug output is enabled. * * @see PHPMailer::$Debugoutput * @see PHPMailer::$SMTPDebug @@ -886,6 +910,7 @@ class PHPMailer switch ($this->Debugoutput) { case 'error_log': //Don't output, just log + /** @noinspection ForgottenDebugOutputInspection */ error_log($str); break; case 'html': @@ -1055,7 +1080,7 @@ class PHPMailer $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim $pos = strrpos($address, '@'); if (false === $pos) { - // At-sign is missing. + //At-sign is missing. $error_message = sprintf( '%s (%s): %s', $this->lang('invalid_address'), @@ -1071,7 +1096,7 @@ class PHPMailer return false; } $params = [$kind, $address, $name]; - // Enqueue addresses with IDN until we know the PHPMailer::$CharSet. + //Enqueue addresses with IDN until we know the PHPMailer::$CharSet. if (static::idnSupported() && $this->has8bitChars(substr($address, ++$pos))) { if ('Reply-To' !== $kind) { if (!array_key_exists($address, $this->RecipientsQueue)) { @@ -1088,7 +1113,7 @@ class PHPMailer return false; } - // Immediately add standard addresses without IDN. + //Immediately add standard addresses without IDN. return call_user_func_array([$this, 'addAnAddress'], $params); } @@ -1164,16 +1189,35 @@ class PHPMailer * * @return array */ - public static function parseAddresses($addrstr, $useimap = true) + public static function parseAddresses($addrstr, $useimap = true, $charset = self::CHARSET_ISO88591) { $addresses = []; if ($useimap && function_exists('imap_rfc822_parse_adrlist')) { //Use this built-in parser if it's available $list = imap_rfc822_parse_adrlist($addrstr, ''); + // Clear any potential IMAP errors to get rid of notices being thrown at end of script. + imap_errors(); foreach ($list as $address) { - if (('.SYNTAX-ERROR.' !== $address->host) && static::validateAddress( - $address->mailbox . '@' . $address->host - )) { + if ( + '.SYNTAX-ERROR.' !== $address->host && + static::validateAddress($address->mailbox . '@' . $address->host) + ) { + //Decode the name part if it's present and encoded + if ( + property_exists($address, 'personal') && + //Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled + defined('MB_CASE_UPPER') && + preg_match('/^=\?.*\?=$/s', $address->personal) + ) { + $origCharset = mb_internal_encoding(); + mb_internal_encoding($charset); + //Undo any RFC2047-encoded spaces-as-underscores + $address->personal = str_replace('_', '=20', $address->personal); + //Decode the name + $address->personal = mb_decode_mimeheader($address->personal); + mb_internal_encoding($origCharset); + } + $addresses[] = [ 'name' => (property_exists($address, 'personal') ? $address->personal : ''), 'address' => $address->mailbox . '@' . $address->host, @@ -1197,9 +1241,22 @@ class PHPMailer } else { list($name, $email) = explode('<', $address); $email = trim(str_replace('>', '', $email)); + $name = trim($name); if (static::validateAddress($email)) { + //Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled + //If this name is encoded, decode it + if (defined('MB_CASE_UPPER') && preg_match('/^=\?.*\?=$/s', $name)) { + $origCharset = mb_internal_encoding(); + mb_internal_encoding($charset); + //Undo any RFC2047-encoded spaces-as-underscores + $name = str_replace('_', '=20', $name); + //Decode the name + $name = mb_decode_mimeheader($name); + mb_internal_encoding($origCharset); + } $addresses[] = [ - 'name' => trim(str_replace(['"', "'"], '', $name)), + //Remove any surrounding quotes and spaces from the name + 'name' => trim($name, '\'" '), 'address' => $email, ]; } @@ -1225,9 +1282,10 @@ class PHPMailer { $address = trim($address); $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim - // Don't validate now addresses with IDN. Will be done in send(). + //Don't validate now addresses with IDN. Will be done in send(). $pos = strrpos($address, '@'); - if ((false === $pos) + if ( + (false === $pos) || ((!$this->has8bitChars(substr($address, ++$pos)) || !static::idnSupported()) && !static::validateAddress($address)) ) { @@ -1295,8 +1353,9 @@ class PHPMailer if (null === $patternselect) { $patternselect = static::$validator; } - if (is_callable($patternselect)) { - return $patternselect($address); + //Don't allow strings as callables, see SECURITY.md and CVE-2021-3603 + if (is_callable($patternselect) && !is_string($patternselect)) { + return call_user_func($patternselect, $address); } //Reject line breaks in addresses; it's valid RFC5322, but not RFC5321 if (strpos($address, "\n") !== false || strpos($address, "\r") !== false) { @@ -1337,7 +1396,7 @@ class PHPMailer /* * This is the pattern used in the HTML5 spec for validation of 'email' type form input elements. * - * @see http://www.whatwg.org/specs/web-apps/current-work/#e-mail-state-(type=email) + * @see https://html.spec.whatwg.org/#e-mail-state-(type=email) */ return (bool) preg_match( '/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}' . @@ -1377,23 +1436,33 @@ class PHPMailer */ public function punyencodeAddress($address) { - // Verify we have required functions, CharSet, and at-sign. + //Verify we have required functions, CharSet, and at-sign. $pos = strrpos($address, '@'); - if (!empty($this->CharSet) && + if ( + !empty($this->CharSet) && false !== $pos && static::idnSupported() ) { $domain = substr($address, ++$pos); - // Verify CharSet string is a valid one, and domain properly encoded in this CharSet. + //Verify CharSet string is a valid one, and domain properly encoded in this CharSet. if ($this->has8bitChars($domain) && @mb_check_encoding($domain, $this->CharSet)) { - $domain = mb_convert_encoding($domain, 'UTF-8', $this->CharSet); + //Convert the domain from whatever charset it's in to UTF-8 + $domain = mb_convert_encoding($domain, self::CHARSET_UTF8, $this->CharSet); //Ignore IDE complaints about this line - method signature changed in PHP 5.4 $errorcode = 0; if (defined('INTL_IDNA_VARIANT_UTS46')) { - $punycode = idn_to_ascii($domain, $errorcode, INTL_IDNA_VARIANT_UTS46); + //Use the current punycode standard (appeared in PHP 7.2) + $punycode = idn_to_ascii( + $domain, + \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | + \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, + \INTL_IDNA_VARIANT_UTS46 + ); } elseif (defined('INTL_IDNA_VARIANT_2003')) { - $punycode = idn_to_ascii($domain, $errorcode, INTL_IDNA_VARIANT_2003); + //Fall back to this old, deprecated/removed encoding + $punycode = idn_to_ascii($domain, $errorcode, \INTL_IDNA_VARIANT_2003); } else { + //Fall back to a default we don't know about $punycode = idn_to_ascii($domain, $errorcode); } if (false !== $punycode) { @@ -1441,36 +1510,33 @@ class PHPMailer */ public function preSend() { - if ('smtp' === $this->Mailer - || ('mail' === $this->Mailer && stripos(PHP_OS, 'WIN') === 0) + if ( + 'smtp' === $this->Mailer + || ('mail' === $this->Mailer && (\PHP_VERSION_ID >= 80000 || stripos(PHP_OS, 'WIN') === 0)) ) { //SMTP mandates RFC-compliant line endings //and it's also used with mail() on Windows - static::setLE("\r\n"); + static::setLE(self::CRLF); } else { //Maintain backward compatibility with legacy Linux command line mailers static::setLE(PHP_EOL); } //Check for buggy PHP versions that add a header with an incorrect line break - if ('mail' === $this->Mailer - && ((PHP_VERSION_ID >= 70000 && PHP_VERSION_ID < 70017) - || (PHP_VERSION_ID >= 70100 && PHP_VERSION_ID < 70103)) + if ( + 'mail' === $this->Mailer + && ((\PHP_VERSION_ID >= 70000 && \PHP_VERSION_ID < 70017) + || (\PHP_VERSION_ID >= 70100 && \PHP_VERSION_ID < 70103)) && ini_get('mail.add_x_header') === '1' && stripos(PHP_OS, 'WIN') === 0 ) { - trigger_error( - 'Your version of PHP is affected by a bug that may result in corrupted messages.' . - ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' . - ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', - E_USER_WARNING - ); + trigger_error($this->lang('buggy_php'), E_USER_WARNING); } try { - $this->error_count = 0; // Reset errors + $this->error_count = 0; //Reset errors $this->mailHeader = ''; - // Dequeue recipient and Reply-To addresses with IDN + //Dequeue recipient and Reply-To addresses with IDN foreach (array_merge($this->RecipientsQueue, $this->ReplyToQueue) as $params) { $params[1] = $this->punyencodeAddress($params[1]); call_user_func_array([$this, 'addAnAddress'], $params); @@ -1479,7 +1545,7 @@ class PHPMailer throw new Exception($this->lang('provide_address'), self::STOP_CRITICAL); } - // Validate From, Sender, and ConfirmReadingTo addresses + //Validate From, Sender, and ConfirmReadingTo addresses foreach (['From', 'Sender', 'ConfirmReadingTo'] as $address_kind) { $this->$address_kind = trim($this->$address_kind); if (empty($this->$address_kind)) { @@ -1503,29 +1569,29 @@ class PHPMailer } } - // Set whether the message is multipart/alternative + //Set whether the message is multipart/alternative if ($this->alternativeExists()) { $this->ContentType = static::CONTENT_TYPE_MULTIPART_ALTERNATIVE; } $this->setMessageType(); - // Refuse to send an empty message unless we are specifically allowing it + //Refuse to send an empty message unless we are specifically allowing it if (!$this->AllowEmpty && empty($this->Body)) { throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL); } //Trim subject consistently $this->Subject = trim($this->Subject); - // Create body before headers in case body makes changes to headers (e.g. altering transfer encoding) + //Create body before headers in case body makes changes to headers (e.g. altering transfer encoding) $this->MIMEHeader = ''; $this->MIMEBody = $this->createBody(); - // createBody may have added some headers, so retain them + //createBody may have added some headers, so retain them $tempheaders = $this->MIMEHeader; $this->MIMEHeader = $this->createHeader(); $this->MIMEHeader .= $tempheaders; - // To capture the complete message when using mail(), create - // an extra header list which createHeader() doesn't fold in + //To capture the complete message when using mail(), create + //an extra header list which createHeader() doesn't fold in if ('mail' === $this->Mailer) { if (count($this->to) > 0) { $this->mailHeader .= $this->addrAppend('To', $this->to); @@ -1538,8 +1604,9 @@ class PHPMailer ); } - // Sign with DKIM if enabled - if (!empty($this->DKIM_domain) + //Sign with DKIM if enabled + if ( + !empty($this->DKIM_domain) && !empty($this->DKIM_selector) && (!empty($this->DKIM_private_string) || (!empty($this->DKIM_private) @@ -1553,7 +1620,7 @@ class PHPMailer $this->encodeHeader($this->secureHeader($this->Subject)), $this->MIMEBody ); - $this->MIMEHeader = rtrim($this->MIMEHeader, "\r\n ") . static::$LE . + $this->MIMEHeader = static::stripTrailingWSP($this->MIMEHeader) . static::$LE . static::normalizeBreaks($header_dkim) . static::$LE; } @@ -1578,7 +1645,7 @@ class PHPMailer public function postSend() { try { - // Choose the mailer and send through it + //Choose the mailer and send through it switch ($this->Mailer) { case 'sendmail': case 'qmail': @@ -1596,6 +1663,9 @@ class PHPMailer return $this->mailSend($this->MIMEHeader, $this->MIMEBody); } } catch (Exception $exc) { + if ($this->Mailer === 'smtp' && $this->SMTPKeepAlive == true) { + $this->smtp->reset(); + } $this->setError($exc->getMessage()); $this->edebug($exc->getMessage()); if ($this->exceptions) { @@ -1620,22 +1690,47 @@ class PHPMailer */ protected function sendmailSend($header, $body) { - $header = rtrim($header, "\r\n ") . static::$LE . static::$LE; + if ($this->Mailer === 'qmail') { + $this->edebug('Sending with qmail'); + } else { + $this->edebug('Sending with sendmail'); + } + $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; + //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver + //A space after `-f` is optional, but there is a long history of its presence + //causing problems, so we don't use one + //Exim docs: http://www.exim.org/exim-html-current/doc/html/spec_html/ch-the_exim_command_line.html + //Sendmail docs: http://www.sendmail.org/~ca/email/man/sendmail.html + //Qmail docs: http://www.qmail.org/man/man8/qmail-inject.html + //Example problem: https://www.drupal.org/node/1057954 - // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. - if (!empty($this->Sender) && self::isShellSafe($this->Sender)) { - if ('qmail' === $this->Mailer) { + //PHP 5.6 workaround + $sendmail_from_value = ini_get('sendmail_from'); + if (empty($this->Sender) && !empty($sendmail_from_value)) { + //PHP config has a sender address we can use + $this->Sender = ini_get('sendmail_from'); + } + //CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. + if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) { + if ($this->Mailer === 'qmail') { $sendmailFmt = '%s -f%s'; } else { $sendmailFmt = '%s -oi -f%s -t'; } - } elseif ('qmail' === $this->Mailer) { - $sendmailFmt = '%s'; } else { + //allow sendmail to choose a default envelope sender. It may + //seem preferable to force it to use the From header as with + //SMTP, but that introduces new problems (see + //), and + //it has historically worked this way. $sendmailFmt = '%s -oi -t'; } $sendmail = sprintf($sendmailFmt, escapeshellcmd($this->Sendmail), $this->Sender); + $this->edebug('Sendmail path: ' . $this->Sendmail); + $this->edebug('Sendmail command: ' . $sendmail); + $this->edebug('Envelope sender: ' . $this->Sender); + $this->edebug("Headers: {$header}"); if ($this->SingleTo) { foreach ($this->SingleToArray as $toAddr) { @@ -1643,13 +1738,15 @@ class PHPMailer if (!$mail) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } + $this->edebug("To: {$toAddr}"); fwrite($mail, 'To: ' . $toAddr . "\n"); fwrite($mail, $header); fwrite($mail, $body); $result = pclose($mail); + $addrinfo = static::parseAddresses($toAddr, true, $this->charSet); $this->doCallback( ($result === 0), - [$toAddr], + [[$addrinfo['address'], $addrinfo['name']]], $this->cc, $this->bcc, $this->Subject, @@ -1657,6 +1754,7 @@ class PHPMailer $this->From, [] ); + $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); if (0 !== $result) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } @@ -1679,6 +1777,7 @@ class PHPMailer $this->From, [] ); + $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); if (0 !== $result) { throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); } @@ -1699,8 +1798,9 @@ class PHPMailer */ protected static function isShellSafe($string) { - // Future-proof - if (escapeshellcmd($string) !== $string + //Future-proof + if ( + escapeshellcmd($string) !== $string || !in_array(escapeshellarg($string), ["'$string'", "\"$string\""]) ) { return false; @@ -1711,9 +1811,9 @@ class PHPMailer for ($i = 0; $i < $length; ++$i) { $c = $string[$i]; - // All other characters have a special meaning in at least one common shell, including = and +. - // Full stop (.) has a special meaning in cmd.exe, but its impact should be negligible here. - // Note that this does permit non-Latin alphanumeric characters based on the current locale. + //All other characters have a special meaning in at least one common shell, including = and +. + //Full stop (.) has a special meaning in cmd.exe, but its impact should be negligible here. + //Note that this does permit non-Latin alphanumeric characters based on the current locale. if (!ctype_alnum($c) && strpos('@_-.', $c) === false) { return false; } @@ -1733,7 +1833,28 @@ class PHPMailer */ protected static function isPermittedPath($path) { - return !preg_match('#^[a-z]+://#i', $path); + //Matches scheme definition from https://tools.ietf.org/html/rfc3986#section-3.1 + return !preg_match('#^[a-z][a-z\d+.-]*://#i', $path); + } + + /** + * Check whether a file path is safe, accessible, and readable. + * + * @param string $path A relative or absolute path to a file + * + * @return bool + */ + protected static function fileIsAccessible($path) + { + if (!static::isPermittedPath($path)) { + return false; + } + $readable = file_exists($path); + //If not a UNC path (expected to start with \\), check read permission, see #2069 + if (strpos($path, '\\\\') !== 0) { + $readable = $readable && is_readable($path); + } + return $readable; } /** @@ -1750,7 +1871,7 @@ class PHPMailer */ protected function mailSend($header, $body) { - $header = rtrim($header, "\r\n ") . static::$LE . static::$LE; + $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; $toArr = []; foreach ($this->to as $toaddr) { @@ -1766,11 +1887,18 @@ class PHPMailer //Sendmail docs: http://www.sendmail.org/~ca/email/man/sendmail.html //Qmail docs: http://www.qmail.org/man/man8/qmail-inject.html //Example problem: https://www.drupal.org/node/1057954 - // CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. - if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) { - $params = sprintf('-f%s', $this->Sender); + //CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. + + //PHP 5.6 workaround + $sendmail_from_value = ini_get('sendmail_from'); + if (empty($this->Sender) && !empty($sendmail_from_value)) { + //PHP config has a sender address we can use + $this->Sender = ini_get('sendmail_from'); } if (!empty($this->Sender) && static::validateAddress($this->Sender)) { + if (self::isShellSafe($this->Sender)) { + $params = sprintf('-f%s', $this->Sender); + } $old_from = ini_get('sendmail_from'); ini_set('sendmail_from', $this->Sender); } @@ -1778,7 +1906,17 @@ class PHPMailer if ($this->SingleTo && count($toArr) > 1) { foreach ($toArr as $toAddr) { $result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params); - $this->doCallback($result, [$toAddr], $this->cc, $this->bcc, $this->Subject, $body, $this->From, []); + $addrinfo = static::parseAddresses($toAddr, true, $this->charSet); + $this->doCallback( + $result, + [[$addrinfo['address'], $addrinfo['name']]], + $this->cc, + $this->bcc, + $this->Subject, + $body, + $this->From, + [] + ); } } else { $result = $this->mailPassthru($to, $this->Subject, $body, $header, $params); @@ -1839,7 +1977,7 @@ class PHPMailer */ protected function smtpSend($header, $body) { - $header = rtrim($header, "\r\n ") . static::$LE . static::$LE; + $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; $bad_rcpt = []; if (!$this->smtpConnect($this->SMTPOptions)) { throw new Exception($this->lang('smtp_connect_failed'), self::STOP_CRITICAL); @@ -1856,7 +1994,7 @@ class PHPMailer } $callbacks = []; - // Attempt to send to all recipients + //Attempt to send to all recipients foreach ([$this->to, $this->cc, $this->bcc] as $togroup) { foreach ($togroup as $to) { if (!$this->smtp->recipient($to[0], $this->dsn)) { @@ -1867,11 +2005,11 @@ class PHPMailer $isSent = true; } - $callbacks[] = ['issent'=>$isSent, 'to'=>$to[0]]; + $callbacks[] = ['issent' => $isSent, 'to' => $to[0], 'name' => $to[1]]; } } - // Only send the DATA command if we have viable recipients + //Only send the DATA command if we have viable recipients if ((count($this->all_recipients) > count($bad_rcpt)) && !$this->smtp->data($header . $body)) { throw new Exception($this->lang('data_not_accepted'), self::STOP_CRITICAL); } @@ -1888,7 +2026,7 @@ class PHPMailer foreach ($callbacks as $cb) { $this->doCallback( $cb['issent'], - [$cb['to']], + [[$cb['to'], $cb['name']]], [], [], $this->Subject, @@ -1933,7 +2071,7 @@ class PHPMailer $options = $this->SMTPOptions; } - // Already connected? + //Already connected? if ($this->smtp->connected()) { return true; } @@ -1947,20 +2085,22 @@ class PHPMailer foreach ($hosts as $hostentry) { $hostinfo = []; - if (!preg_match( - '/^(?:(ssl|tls):\/\/)?(.+?)(?::(\d+))?$/', - trim($hostentry), - $hostinfo - )) { + if ( + !preg_match( + '/^(?:(ssl|tls):\/\/)?(.+?)(?::(\d+))?$/', + trim($hostentry), + $hostinfo + ) + ) { $this->edebug($this->lang('invalid_hostentry') . ' ' . trim($hostentry)); - // Not a valid host entry + //Not a valid host entry continue; } - // $hostinfo[1]: optional ssl or tls prefix - // $hostinfo[2]: the hostname - // $hostinfo[3]: optional port number - // The host string prefix can temporarily override the current setting for SMTPSecure - // If it's not specified, the default value is used + //$hostinfo[1]: optional ssl or tls prefix + //$hostinfo[2]: the hostname + //$hostinfo[3]: optional port number + //The host string prefix can temporarily override the current setting for SMTPSecure + //If it's not specified, the default value is used //Check the host name is a valid name or IP address before trying to use it if (!static::isValidHost($hostinfo[2])) { @@ -1972,11 +2112,11 @@ class PHPMailer $tls = (static::ENCRYPTION_STARTTLS === $this->SMTPSecure); if ('ssl' === $hostinfo[1] || ('' === $hostinfo[1] && static::ENCRYPTION_SMTPS === $this->SMTPSecure)) { $prefix = 'ssl://'; - $tls = false; // Can't have SSL and TLS at the same time + $tls = false; //Can't have SSL and TLS at the same time $secure = static::ENCRYPTION_SMTPS; } elseif ('tls' === $hostinfo[1]) { $tls = true; - // tls doesn't use a prefix + //TLS doesn't use a prefix $secure = static::ENCRYPTION_STARTTLS; } //Do we need the OpenSSL extension? @@ -1989,7 +2129,12 @@ class PHPMailer } $host = $hostinfo[2]; $port = $this->Port; - if (array_key_exists(3, $hostinfo) && is_numeric($hostinfo[3]) && $hostinfo[3] > 0 && $hostinfo[3] < 65536) { + if ( + array_key_exists(3, $hostinfo) && + is_numeric($hostinfo[3]) && + $hostinfo[3] > 0 && + $hostinfo[3] < 65536 + ) { $port = (int) $hostinfo[3]; } if ($this->smtp->connect($prefix . $host, $port, $this->Timeout, $options)) { @@ -2001,10 +2146,10 @@ class PHPMailer } $this->smtp->hello($hello); //Automatically enable TLS encryption if: - // * it's not disabled - // * we have openssl extension - // * we are not already using SSL - // * the server offers STARTTLS + //* it's not disabled + //* we have openssl extension + //* we are not already using SSL + //* the server offers STARTTLS if ($this->SMTPAutoTLS && $sslext && 'ssl' !== $secure && $this->smtp->getServerExt('STARTTLS')) { $tls = true; } @@ -2012,15 +2157,17 @@ class PHPMailer if (!$this->smtp->startTLS()) { throw new Exception($this->lang('connect_host')); } - // We must resend EHLO after TLS negotiation + //We must resend EHLO after TLS negotiation $this->smtp->hello($hello); } - if ($this->SMTPAuth && !$this->smtp->authenticate( - $this->Username, - $this->Password, - $this->AuthType, - $this->oauth - )) { + if ( + $this->SMTPAuth && !$this->smtp->authenticate( + $this->Username, + $this->Password, + $this->AuthType, + $this->oauth + ) + ) { throw new Exception($this->lang('authenticate')); } @@ -2028,14 +2175,14 @@ class PHPMailer } catch (Exception $exc) { $lastexception = $exc; $this->edebug($exc->getMessage()); - // We must have connected, but then failed TLS or Auth, so close connection nicely + //We must have connected, but then failed TLS or Auth, so close connection nicely $this->smtp->quit(); } } } - // If we get here, all connection attempts have failed, so close connection hard + //If we get here, all connection attempts have failed, so close connection hard $this->smtp->close(); - // As we've caught all exceptions, just report whatever the last one was + //As we've caught all exceptions, just report whatever the last one was if ($this->exceptions && null !== $lastexception) { throw $lastexception; } @@ -2056,17 +2203,19 @@ class PHPMailer /** * Set the language for error messages. - * Returns false if it cannot load the language file. * The default language is English. * * @param string $langcode ISO 639-1 2-character language code (e.g. French is "fr") + * Optionally, the language code can be enhanced with a 4-character + * script annotation and/or a 2-character country annotation. * @param string $lang_path Path to the language file directory, with trailing separator (slash) + * Do not set this from user input! * - * @return bool + * @return bool Returns true if the requested language was loaded, false otherwise. */ public function setLanguage($langcode = 'en', $lang_path = '') { - // Backwards compatibility for renamed language codes + //Backwards compatibility for renamed language codes $renamed_langcodes = [ 'br' => 'pt_br', 'cz' => 'cs', @@ -2075,60 +2224,112 @@ class PHPMailer 'se' => 'sv', 'rs' => 'sr', 'tg' => 'tl', + 'am' => 'hy', ]; - if (isset($renamed_langcodes[$langcode])) { + if (array_key_exists($langcode, $renamed_langcodes)) { $langcode = $renamed_langcodes[$langcode]; } - // Define full set of translatable strings in English + //Define full set of translatable strings in English $PHPMAILER_LANG = [ 'authenticate' => 'SMTP Error: Could not authenticate.', + 'buggy_php' => 'Your version of PHP is affected by a bug that may result in corrupted messages.' . + ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' . + ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', 'connect_host' => 'SMTP Error: Could not connect to SMTP host.', 'data_not_accepted' => 'SMTP Error: data not accepted.', 'empty_message' => 'Message body empty', 'encoding' => 'Unknown encoding: ', 'execute' => 'Could not execute: ', + 'extension_missing' => 'Extension missing: ', 'file_access' => 'Could not access file: ', 'file_open' => 'File Error: Could not open file: ', 'from_failed' => 'The following From address failed: ', 'instantiate' => 'Could not instantiate mail function.', 'invalid_address' => 'Invalid address: ', + 'invalid_header' => 'Invalid header name or value', 'invalid_hostentry' => 'Invalid hostentry: ', 'invalid_host' => 'Invalid host: ', 'mailer_not_supported' => ' mailer is not supported.', 'provide_address' => 'You must provide at least one recipient email address.', 'recipients_failed' => 'SMTP Error: The following recipients failed: ', 'signing' => 'Signing Error: ', + 'smtp_code' => 'SMTP code: ', + 'smtp_code_ex' => 'Additional SMTP info: ', 'smtp_connect_failed' => 'SMTP connect() failed.', + 'smtp_detail' => 'Detail: ', 'smtp_error' => 'SMTP server error: ', 'variable_set' => 'Cannot set or reset variable: ', - 'extension_missing' => 'Extension missing: ', ]; if (empty($lang_path)) { - // Calculate an absolute path so it can work if CWD is not here + //Calculate an absolute path so it can work if CWD is not here $lang_path = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR; } + //Validate $langcode - if (!preg_match('/^[a-z]{2}(?:_[a-zA-Z]{2})?$/', $langcode)) { + $foundlang = true; + $langcode = strtolower($langcode); + if ( + !preg_match('/^(?P[a-z]{2})(?P + + + + + + + Account - {{ view_title }} + {% if site.recaptcha.enabled %} + + {% endif %} +{% endblock %} + +{% block body %} +
+
+
+
+ +

{{ view_title }}

+

{{ view_description }}

+
+
+
+ {% block view_content %}{% endblock %} + +
+
+
+ {% if site.recaptcha.enabled %} + + {% endif %} +{% endblock %} diff --git a/core/Templates/account/accept_invite.twig b/core/Templates/account/accept_invite.twig new file mode 100644 index 0000000..73a3268 --- /dev/null +++ b/core/Templates/account/accept_invite.twig @@ -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 %} + + Back to login + {% else %} +

Please fill with your details

+
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ {% endif %} + +{% endblock %} \ No newline at end of file diff --git a/core/Templates/account/confirm_email.twig b/core/Templates/account/confirm_email.twig new file mode 100644 index 0000000..2a558fd --- /dev/null +++ b/core/Templates/account/confirm_email.twig @@ -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 %} + +
+ Confirming email… +
+ + +{% endblock %} \ No newline at end of file diff --git a/core/Templates/account/register.twig b/core/Templates/account/register.twig new file mode 100644 index 0000000..577bd8c --- /dev/null +++ b/core/Templates/account/register.twig @@ -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 %} + + Go back + {% else %} +

Please fill with your details

+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+ +
+ {% endif %} + +{% endblock %} \ No newline at end of file diff --git a/core/Templates/account/resend_confirm_email.twig b/core/Templates/account/resend_confirm_email.twig new file mode 100644 index 0000000..295e30e --- /dev/null +++ b/core/Templates/account/resend_confirm_email.twig @@ -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 %} +

Enter your E-Mail address, to receive a new e-mail to confirm your registration.

+
+
+
+ +
+ +
+
+ + + + +
+
+{% endblock %} diff --git a/core/Templates/account/reset_password.twig b/core/Templates/account/reset_password.twig new file mode 100644 index 0000000..0dbf43c --- /dev/null +++ b/core/Templates/account/reset_password.twig @@ -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 %} + + Go back + {% else %} +

Choose a new password

+
+ +
+
+ +
+ +
+
+
+ +
+ +
+ +
+ {% endif %} + {% else %} +

Enter your E-Mail address, to receive a password reset token.

+
+
+
+ +
+ +
+
+ + + + +
+
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/core/Templates/admin.twig b/core/Templates/admin.twig new file mode 100644 index 0000000..2031fcd --- /dev/null +++ b/core/Templates/admin.twig @@ -0,0 +1,12 @@ +{% extends "base.twig" %} + +{% block head %} + {{ site.name }} - Administration + +{% endblock %} + +{% block body %} + +
+ +{% endblock %} diff --git a/core/Templates/base.twig b/core/Templates/base.twig new file mode 100644 index 0000000..57af4bc --- /dev/null +++ b/core/Templates/base.twig @@ -0,0 +1,15 @@ + + + + + + + {% block head %} + {{ site.title }} + {% endblock %} + + + {% block body %} + {% endblock %} + + \ No newline at end of file diff --git a/core/Templates/mail/accept_invite.twig b/core/Templates/mail/accept_invite.twig new file mode 100644 index 0000000..d3dfd73 --- /dev/null +++ b/core/Templates/mail/accept_invite.twig @@ -0,0 +1,8 @@ +Hello {{ username }},

+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 }}:

+ +{{ link }}

+ +Best Regards
+{{ site_name }} Administration \ No newline at end of file diff --git a/core/Templates/mail/confirm_email.twig b/core/Templates/mail/confirm_email.twig new file mode 100644 index 0000000..a4c9bcb --- /dev/null +++ b/core/Templates/mail/confirm_email.twig @@ -0,0 +1,8 @@ +Hello {{ username }},

+You recently created an account on {{ site_name }}. Please click on the following link to confirm your email address and complete your registration.
+If you haven't registered an account, you can simply ignore this email. The link is valid for {{ valid_time }}:

+ +{{ link }}

+ +Best Regards
+{{ site_name }} Administration \ No newline at end of file diff --git a/core/Templates/mail/reset_password.twig b/core/Templates/mail/reset_password.twig new file mode 100644 index 0000000..def85f8 --- /dev/null +++ b/core/Templates/mail/reset_password.twig @@ -0,0 +1,8 @@ +Hello {{ username }},
+you requested a password reset on {{ site_name }}. Please click on the following link to choose a new password.
+If this request was not intended, you can simply ignore the email. The Link is valid for {{ valid_time }}:

+ +{{ link }}

+ +Best Regards
+{{ site_name }} Administration \ No newline at end of file diff --git a/core/Templates/redirect.twig b/core/Templates/redirect.twig new file mode 100644 index 0000000..d0e0944 --- /dev/null +++ b/core/Templates/redirect.twig @@ -0,0 +1,7 @@ +{% extends "base.twig" %} +{% block head %} + +{% endblock %} +{% block body %} + You will be automatically redirected to {{ url }}. If that doesn't work, click here. +{% endblock %} \ No newline at end of file diff --git a/core/Views/Account/AcceptInvite.class.php b/core/Views/Account/AcceptInvite.class.php deleted file mode 100644 index 8dc47c9..0000000 --- a/core/Views/Account/AcceptInvite.class.php +++ /dev/null @@ -1,89 +0,0 @@ -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 "

Please fill with your details

-
- -
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- -
-
"; - } -} \ No newline at end of file diff --git a/core/Views/Account/AccountView.class.php b/core/Views/Account/AccountView.class.php deleted file mode 100644 index f14e033..0000000 --- a/core/Views/Account/AccountView.class.php +++ /dev/null @@ -1,61 +0,0 @@ -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 .= "
-
-
-
- $icon -

$this->title

-

$this->description

-
-
-
- $content - -
-
-
"; - - $settings = $this->getDocument()->getUser()->getConfiguration()->getSettings(); - if ($settings->isRecaptchaEnabled()) { - $siteKey = $settings->getRecaptchaSiteKey(); - $html .= ""; - } - - return $html; - } - - protected abstract function getAccountContent(); -} \ No newline at end of file diff --git a/core/Views/Account/ConfirmEmail.class.php b/core/Views/Account/ConfirmEmail.class.php deleted file mode 100644 index 8f77287..0000000 --- a/core/Views/Account/ConfirmEmail.class.php +++ /dev/null @@ -1,55 +0,0 @@ -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 = " -
- Confirming email… $spinner -
"; - - $html .= ""; - return $html; - } -} \ No newline at end of file diff --git a/core/Views/Account/Register.class.php b/core/Views/Account/Register.class.php deleted file mode 100644 index 2a7108e..0000000 --- a/core/Views/Account/Register.class.php +++ /dev/null @@ -1,70 +0,0 @@ -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 /admin/settings, to enable the user registration" - ); - } - - return "

Please fill with your details

-
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
- -
"; - } -} \ No newline at end of file diff --git a/core/Views/Account/ResendConfirmEmail.class.php b/core/Views/Account/ResendConfirmEmail.class.php deleted file mode 100644 index 30cad73..0000000 --- a/core/Views/Account/ResendConfirmEmail.class.php +++ /dev/null @@ -1,39 +0,0 @@ -title = "Resend Confirm Email"; - $this->description = "Request a new confirmation email to finalize the account creation"; - $this->icon = "envelope"; - } - - protected function getAccountContent() { - return "

Enter your E-Mail address, to receive a new e-mail to confirm your registration.

-
-
-
- -
- -
-
- - - - -
- "; - } -} \ No newline at end of file diff --git a/core/Views/Account/ResetPassword.class.php b/core/Views/Account/ResetPassword.class.php deleted file mode 100644 index 28d013f..0000000 --- a/core/Views/Account/ResetPassword.class.php +++ /dev/null @@ -1,94 +0,0 @@ -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 .= "Go back"; - } - return $html; - } - - if ($this->token === null) { - return "

Enter your E-Mail address, to receive a password reset token.

- -
-
- -
- -
-
- - - - -
- "; - } else { - return "

Choose a new password

- - -
-
- -
- -
-
-
- -
- -
-
- -
-
"; - } - } -} \ No newline at end of file diff --git a/core/Views/Admin/AdminDashboardBody.class.php b/core/Views/Admin/AdminDashboardBody.class.php deleted file mode 100644 index 758b595..0000000 --- a/core/Views/Admin/AdminDashboardBody.class.php +++ /dev/null @@ -1,20 +0,0 @@ -
$script
"; - return $html; - } -} diff --git a/core/Views/Admin/LoginBody.class.php b/core/Views/Admin/LoginBody.class.php deleted file mode 100644 index b40285a..0000000 --- a/core/Views/Admin/LoginBody.class.php +++ /dev/null @@ -1,72 +0,0 @@ -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 .= " - -
-
-

Admin Control Panel

-
-
-
-
- - - - -
- - -
- - - $flags -
- -
-
-
- "; - - return $html; - } -} diff --git a/core/Views/View404.class.php b/core/Views/View404.class.php deleted file mode 100644 index d3ebe0f..0000000 --- a/core/Views/View404.class.php +++ /dev/null @@ -1,13 +0,0 @@ -Not found"; - } - -}; \ No newline at end of file diff --git a/core/core.php b/core/core.php index ef65d23..c50fde5 100644 --- a/core/core.php +++ b/core/core.php @@ -1,14 +1,18 @@ 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))); diff --git a/docker/nginx/site.conf b/docker/nginx/site.conf index dfd1b05..726f5c8 100644 --- a/docker/nginx/site.conf +++ b/docker/nginx/site.conf @@ -1,46 +1,51 @@ server { - index index.php index.html; - error_log /var/log/nginx/error.log; - access_log /var/log/nginx/access.log; - root /application; + index index.php index.html; + error_log /var/log/nginx/error.log; + access_log /var/log/nginx/access.log; + root /application; - # rewrite api - rewrite ^/api(/.*)$ /index.php?api=$1; + # rewrite api + rewrite ^/api(/.*)$ /index.php?api=$1; - # deny access to .gitignore / .htaccess - location ~ /\. { - rewrite ^(.*)$ /index.php?site=$1; + # deny access to .gitignore / .htaccess + location ~ /\. { + rewrite ^(.*)$ /index.php?site=$1; + } + + # deny access to docker-compose.yml + location /docker-compose.yml { + rewrite ^(.*)$ /index.php?site=$1; + } + + # deny access to specific directories + location ~ ^/(files/uploaded|adminPanel|fileControlPanel|docker|core|test)/.*$ { + rewrite ^(.*)$ /index.php?site=$1; + } + + # caching + location ~ ^/(static|js|css)/.*$ { + add_header "Cache-Control" "max-age=0; must-revalidate"; } - # deny access to docker-compose.yml - location /docker-compose.yml { - rewrite ^(.*)$ /index.php?site=$1; - } + # try to find the specified file + location / { + try_files $uri $uri @redirectToIndex; + } - # deny access to specific directories - location ~ ^/(files/uploaded|adminPanel|fileControlPanel|docker|core)/.*$ { - rewrite ^(.*)$ /index.php?site=$1; - } + # redirect to index.php + location @redirectToIndex { + rewrite ^(.*)$ /index.php?site=$1; + } - # try to find the specified file - location / { - try_files $uri $uri @redirectToIndex; - } - - # redirect to index.php - location @redirectToIndex { - rewrite ^(.*)$ /index.php?site=$1; - } - - # serve .php files - location ~ \.php$ { - try_files $uri =404; - fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass php:9000; - fastcgi_index index.php; - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param PATH_INFO $fastcgi_path_info; - } + # serve .php files + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass php:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + } } \ No newline at end of file diff --git a/index.php b/index.php index 8c324a7..3a6681c 100644 --- a/index.php +++ b/index.php @@ -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)); diff --git a/js/account.js b/js/account.js index c446e0a..49b9787 100644 --- a/js/account.js +++ b/js/account.js @@ -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(); } }); } diff --git a/test/AesStream.test.php b/test/AesStream.test.php new file mode 100644 index 0000000..3e8e1d3 --- /dev/null +++ b/test/AesStream.test.php @@ -0,0 +1,143 @@ +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); + } +} \ No newline at end of file diff --git a/test/Parameter.test.php b/test/Parameter.test.php new file mode 100644 index 0000000..261e91f --- /dev/null +++ b/test/Parameter.test.php @@ -0,0 +1,108 @@ +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")); + } +} \ No newline at end of file diff --git a/test/Request.test.php b/test/Request.test.php new file mode 100644 index 0000000..a6c7388 --- /dev/null +++ b/test/Request.test.php @@ -0,0 +1,192 @@ +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; + } +} \ No newline at end of file