From 918244125ca8fda55742d420fb27975184f0615e Mon Sep 17 00:00:00 2001
From: Roman
Date: Wed, 8 Dec 2021 16:53:43 +0100
Subject: [PATCH] Twig, Tests, AES,
---
cli.php | 122 +-
core/Api/MailAPI.class.php | 19 +-
core/Api/NotificationsAPI.class.php | 3 +-
core/Api/PermissionAPI.class.php | 7 +-
core/Api/Request.class.php | 124 +-
core/Api/SettingsAPI.class.php | 4 +-
core/Api/TemplateAPI.class.php | 78 +
core/Api/UserAPI.class.php | 534 ++--
core/Configuration/Settings.class.php | 1 +
core/Documents/Account.class.php | 110 +-
core/Documents/Admin.class.php | 54 +-
core/Documents/Document404.class.php | 70 +-
core/Documents/Install.class.php | 4 +-
core/Driver/SQL/Column/BigIntColumn.php | 13 +
core/Driver/SQL/Column/EnumColumn.class.php | 4 +
core/Driver/SQL/Column/IntColumn.class.php | 14 +-
core/Driver/SQL/Condition/CondIn.class.php | 14 +-
.../SQL/Expression/JsonArrayAgg.class.php | 18 +
core/Driver/SQL/Expression/Sum.class.php | 2 -
core/Driver/SQL/MySQL.class.php | 9 +-
core/Driver/SQL/PostgreSQL.class.php | 7 +-
core/Driver/SQL/Query/AlterTable.class.php | 20 +
core/Driver/SQL/Query/CreateTable.class.php | 9 +-
core/Driver/SQL/SQL.class.php | 25 +-
core/Elements/Document.class.php | 73 +-
core/Elements/HtmlDocument.class.php | 79 +
core/Elements/Link.class.php | 19 +-
core/Elements/Script.class.php | 20 +-
core/Elements/TemplateDocument.class.php | 71 +
core/External/.gitignore | 1 +
core/External/PHPMailer/Exception.php | 5 +-
core/External/PHPMailer/OAuth.php | 5 +-
core/External/PHPMailer/PHPMailer.php | 882 ++++---
core/External/PHPMailer/POP3.php | 101 +-
core/External/PHPMailer/SMTP.php | 237 +-
core/External/ZipStream/File.php | 164 +-
core/External/ZipStream/FileStream.class.php | 46 +
core/External/ZipStream/ZipStream.php | 60 +-
core/External/composer.json | 8 +
core/External/composer.lock | 2269 +++++++++++++++++
core/Objects/AesStream.class.php | 90 +-
core/Objects/Language.class.php | 3 -
core/Objects/Session.class.php | 30 +-
core/Objects/User.class.php | 32 +-
core/TemplateCache/.gitkeep | 0
core/Templates/404.twig | 4 +
core/Templates/account.twig | 36 +
core/Templates/account/accept_invite.twig | 46 +
core/Templates/account/confirm_email.twig | 37 +
core/Templates/account/register.twig | 50 +
.../account/resend_confirm_email.twig | 27 +
core/Templates/account/reset_password.twig | 59 +
core/Templates/admin.twig | 12 +
core/Templates/base.twig | 15 +
core/Templates/mail/accept_invite.twig | 8 +
core/Templates/mail/confirm_email.twig | 8 +
core/Templates/mail/reset_password.twig | 8 +
core/Templates/redirect.twig | 7 +
core/Views/Account/AcceptInvite.class.php | 89 -
core/Views/Account/AccountView.class.php | 61 -
core/Views/Account/ConfirmEmail.class.php | 55 -
core/Views/Account/Register.class.php | 70 -
.../Account/ResendConfirmEmail.class.php | 39 -
core/Views/Account/ResetPassword.class.php | 94 -
core/Views/Admin/AdminDashboardBody.class.php | 20 -
core/Views/Admin/LoginBody.class.php | 72 -
core/Views/View404.class.php | 13 -
core/core.php | 34 +-
docker/nginx/site.conf | 79 +-
index.php | 15 +-
js/account.js | 4 +-
test/AesStream.test.php | 143 ++
test/Parameter.test.php | 108 +
test/Request.test.php | 192 ++
74 files changed, 5350 insertions(+), 1515 deletions(-)
create mode 100644 core/Api/TemplateAPI.class.php
create mode 100644 core/Driver/SQL/Column/BigIntColumn.php
create mode 100644 core/Driver/SQL/Expression/JsonArrayAgg.class.php
create mode 100644 core/Elements/HtmlDocument.class.php
create mode 100644 core/Elements/TemplateDocument.class.php
create mode 100644 core/External/.gitignore
create mode 100644 core/External/ZipStream/FileStream.class.php
create mode 100644 core/External/composer.json
create mode 100644 core/External/composer.lock
create mode 100644 core/TemplateCache/.gitkeep
create mode 100644 core/Templates/404.twig
create mode 100644 core/Templates/account.twig
create mode 100644 core/Templates/account/accept_invite.twig
create mode 100644 core/Templates/account/confirm_email.twig
create mode 100644 core/Templates/account/register.twig
create mode 100644 core/Templates/account/resend_confirm_email.twig
create mode 100644 core/Templates/account/reset_password.twig
create mode 100644 core/Templates/admin.twig
create mode 100644 core/Templates/base.twig
create mode 100644 core/Templates/mail/accept_invite.twig
create mode 100644 core/Templates/mail/confirm_email.twig
create mode 100644 core/Templates/mail/reset_password.twig
create mode 100644 core/Templates/redirect.twig
delete mode 100644 core/Views/Account/AcceptInvite.class.php
delete mode 100644 core/Views/Account/AccountView.class.php
delete mode 100644 core/Views/Account/ConfirmEmail.class.php
delete mode 100644 core/Views/Account/Register.class.php
delete mode 100644 core/Views/Account/ResendConfirmEmail.class.php
delete mode 100644 core/Views/Account/ResetPassword.class.php
delete mode 100644 core/Views/Admin/AdminDashboardBody.class.php
delete mode 100644 core/Views/Admin/LoginBody.class.php
delete mode 100644 core/Views/View404.class.php
create mode 100644 test/AesStream.test.php
create mode 100644 test/Parameter.test.php
create mode 100644 test/Request.test.php
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, "$body";
+ }
+ if (stripos($body, "$body";
+ }
try {
$mail = new PHPMailer;
@@ -119,9 +132,9 @@ namespace Api\Mail {
$mail->Username = $mailConfig->getLogin();
$mail->Password = $mailConfig->getPassword();
$mail->SMTPSecure = 'tls';
- $mail->IsHTML(true);
$mail->CharSet = 'UTF-8';
- $mail->Body = $this->getParam('body');
+ $mail->msgHTML($body);
+ $mail->AltBody = strip_tags($body);
$this->success = @$mail->Send();
if (!$this->success) {
@@ -212,7 +225,7 @@ namespace Api\Mail {
if ($this->success && count($entityIds) > 0) {
$sql->update("EntityLog")
->set("modified", $sql->now())
- ->where(new CondIn("entityId", $entityIds))
+ ->where(new CondIn(new Column("entityId"), $entityIds))
->execute();
}
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 %}
+ {{ view.message }}
+ 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 %}
+
+ Javascript is required
+
+
+ Confirming email…
+
+ Proceed to Login
+
+{% 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 %}
+ {{ view.message }}
+ 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 %}
+ {{ view.message }}
+ 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 %}
+ You need Javascript enabled to run this app
+
+
+{% 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
-
-
-
-
-
";
-
- $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 = "Javascript is required
-
- Confirming email… $spinner
-
";
-
- $html .= "Proceed to Login ";
- 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/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
-
-
-
Admin Control Panel
-
-
-
-