Twig, Tests, AES,

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

View File

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

View File

@@ -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()))))

View File

@@ -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);

View File

@@ -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());
}
}
}

View File

@@ -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);

View File

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

View File

@@ -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;
}
}
}