Core v2.3, N:M Relations

This commit is contained in:
Roman 2022-11-20 17:13:53 +01:00
parent b5b8f9b856
commit 303a5b69b5
41 changed files with 962 additions and 1047 deletions

@ -1,291 +0,0 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class ContactAPI extends Request {
protected ?string $messageId;
public function __construct(Context $context, bool $externalCall, array $params) {
parent::__construct($context, $externalCall, $params);
$this->messageId = null;
$this->csrfTokenRequired = false;
}
protected function sendMail(string $name, ?string $fromEmail, string $subject, string $message, ?string $to = null): bool {
$request = new \Core\API\Mail\Send($this->context);
$this->success = $request->execute(array(
"subject" => $subject,
"body" => $message,
"replyTo" => $fromEmail,
"replyName" => $name,
"to" => $to
));
$this->lastError = $request->getLastError();
if ($this->success) {
$this->messageId = $request->getResult()["messageId"];
}
return $this->success;
}
}
}
namespace Core\API\Contact {
use Core\API\ContactAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\VerifyCaptcha;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondNot;
use Core\Driver\SQL\Expression\CaseWhen;
use Core\Driver\SQL\Expression\Sum;
use Core\Objects\Context;
class Request extends ContactAPI {
public function __construct(Context $context, bool $externalCall = false) {
$parameters = array(
'fromName' => new StringType('fromName', 32),
'fromEmail' => new Parameter('fromEmail', Parameter::TYPE_EMAIL),
'message' => new StringType('message', 512),
);
$settings = $context->getSettings();
if ($settings->isRecaptchaEnabled()) {
$parameters["captcha"] = new StringType("captcha");
}
parent::__construct($context, $externalCall, $parameters);
}
public function _execute(): bool {
$settings = $this->context->getSettings();
if ($settings->isRecaptchaEnabled()) {
$captcha = $this->getParam("captcha");
$req = new VerifyCaptcha($this->context);
if (!$req->execute(array("captcha" => $captcha, "action" => "contact"))) {
return $this->createError($req->getLastError());
}
}
// parameter
$message = $this->getParam("message");
$name = $this->getParam("fromName");
$email = $this->getParam("fromEmail");
$sendMail = $this->sendMail($name, $email, "Contact Request", $message);
$insertDB = $this->insertContactRequest();
if (!$sendMail && !$insertDB) {
return $this->createError("The contact request could not be sent. The Administrator is already informed. Please try again later.");
}
return $this->success;
}
private function insertContactRequest(): bool {
$sql = $this->context->getSQL();
$name = $this->getParam("fromName");
$email = $this->getParam("fromEmail");
$message = $this->getParam("message");
$messageId = $this->messageId ?? null;
$res = $sql->insert("ContactRequest", array("from_name", "from_email", "message", "messageId"))
->addRow($name, $email, $message, $messageId)
->returning("id")
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success;
}
}
class Respond extends ContactAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"requestId" => new Parameter("requestId", Parameter::TYPE_INT),
'message' => new StringType('message', 512),
));
$this->loginRequired = true;
}
private function getSenderMail(): ?string {
$requestId = $this->getParam("requestId");
$sql = $this->context->getSQL();
$res = $sql->select("from_email")
->from("ContactRequest")
->where(new Compare("id", $requestId))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success) {
if (empty($res)) {
return $this->createError("Request does not exist");
} else {
return $res[0]["from_email"];
}
}
return null;
}
private function insertResponseMessage(): bool {
$sql = $this->context->getSQL();
$message = $this->getParam("message");
$requestId = $this->getParam("requestId");
$this->success = $sql->insert("ContactMessage", ["request_id", "user_id", "message", "messageId", "read"])
->addRow($requestId, $this->context->getUser()->getId(), $message, $this->messageId, true)
->execute();
$this->lastError = $sql->getLastError();
return $this->success;
}
private function updateEntity() {
$sql = $this->context->getSQL();
$requestId = $this->getParam("requestId");
$sql->update("EntityLog")
->set("modified", $sql->now())
->where(new Compare("entityId", $requestId))
->execute();
}
public function _execute(): bool {
$message = $this->getParam("message");
$senderMail = $this->getSenderMail();
if (!$this->success) {
return false;
}
$user = $this->context->getUser();
$fromName = $user->getUsername();
$fromEmail = $user->getEmail();
if (!$this->sendMail($fromName, $fromEmail, "Re: Contact Request", $message, $senderMail)) {
return false;
}
if (!$this->insertResponseMessage()) {
return false;
}
$this->updateEntity();
return $this->success;
}
}
class Fetch extends ContactAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array());
$this->loginRequired = true;
$this->csrfTokenRequired = false;
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$res = $sql->select("ContactRequest.id", "from_name", "from_email", "from_name",
new Sum(new CaseWhen(new CondNot("ContactMessage.read"), 1, 0), "unread"))
->from("ContactRequest")
->groupBy("ContactRequest.id")
->leftJoin("ContactMessage", "ContactRequest.id", "ContactMessage.request_id")
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["contactRequests"] = [];
foreach ($res as $row) {
$this->result["contactRequests"][] = array(
"id" => intval($row["id"]),
"from_name" => $row["from_name"],
"from_email" => $row["from_email"],
"unread" => intval($row["unread"]),
);
}
}
return $this->success;
}
}
class Get extends ContactAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"requestId" => new Parameter("requestId", Parameter::TYPE_INT),
));
$this->loginRequired = true;
$this->csrfTokenRequired = false;
}
private function updateRead() {
$requestId = $this->getParam("requestId");
$sql = $this->context->getSQL();
$sql->update("ContactMessage")
->set("read", 1)
->where(new Compare("request_id", $requestId))
->execute();
}
public function _execute(): bool {
$requestId = $this->getParam("requestId");
$sql = $this->context->getSQL();
$res = $sql->select("from_name", "from_email", "message", "created_at")
->from("ContactRequest")
->where(new Compare("id", $requestId))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success) {
if (empty($res)) {
return $this->createError("Request does not exist");
} else {
$row = $res[0];
$this->result["request"] = array(
"from_name" => $row["from_name"],
"from_email" => $row["from_email"],
"messages" => array(
["sender_id" => null, "message" => $row["message"], "timestamp" => $row["created_at"]]
)
);
$res = $sql->select("user_id", "message", "created_at")
->from("ContactMessage")
->where(new Compare("request_id", $requestId))
->orderBy("created_at")
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success) {
foreach ($res as $row) {
$this->result["request"]["messages"][] = array(
"sender_id" => $row["user_id"], "message" => $row["message"], "timestamp" => $row["created_at"]
);
}
$this->updateRead();
}
}
}
return $this->success;
}
}
}

@ -31,6 +31,7 @@ namespace Core\API\Groups {
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Controller\NMRelation;
use Core\Objects\DatabaseEntity\Group;
class Fetch extends GroupsAPI {
@ -46,20 +47,6 @@ namespace Core\API\Groups {
$this->groupCount = 0;
}
private function fetchGroupCount(): bool {
$sql = $this->context->getSQL();
$res = $sql->select($sql->count())->from("Group")->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->groupCount = $res[0]["count"];
}
return $this->success;
}
public function _execute(): bool {
$page = $this->getParam("page");
if($page < 1) {
@ -71,39 +58,42 @@ namespace Core\API\Groups {
return $this->createError("Invalid fetch count");
}
if (!$this->fetchGroupCount()) {
return false;
$sql = $this->context->getSQL();
$groupCount = Group::count($sql);
if ($groupCount === false) {
return $this->createError("Error fetching group count: " . $sql->getLastError());
}
$sql = $this->context->getSQL();
$res = $sql->select("Group.id as groupId", "Group.name as groupName", "Group.color as groupColor", $sql->count("UserGroup.user_id"))
->from("Group")
->leftJoin("UserGroup", "UserGroup.group_id", "Group.id")
->groupBy("Group.id")
->orderBy("Group.id")
$groups = Group::findBy(Group::createBuilder($sql, false)
->orderBy("id")
->ascending()
->limit($count)
->offset(($page - 1) * $count)
->execute();
->offset(($page - 1) * $count));
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if($this->success) {
$this->result["groups"] = array();
foreach($res as $row) {
$groupId = intval($row["groupId"]);
$groupName = $row["groupName"];
$groupColor = $row["groupColor"];
$memberCount = $row["usergroup_user_id_count"];
$this->result["groups"][$groupId] = array(
"name" => $groupName,
"memberCount" => $memberCount,
"color" => $groupColor,
);
}
if ($groups !== false) {
$this->result["groups"] = [];
$this->result["pageCount"] = intval(ceil($this->groupCount / $count));
$this->result["totalCount"] = $this->groupCount;
foreach ($groups as $groupId => $group) {
$this->result["groups"][$groupId] = $group->jsonSerialize();
$this->result["groups"][$groupId]["memberCount"] = 0;
}
$nmTable = NMRelation::buildTableName("User", "Group");
$res = $sql->select("group_id", $sql->count("user_id"))
->from($nmTable)
->groupBy("group_id")
->execute();
if (is_array($res)) {
foreach ($res as $row) {
list ($groupId, $memberCount) = [$row["group_id"], $row["user_id_count"]];
if (isset($this->result["groups"][$groupId])) {
$this->result["groups"][$groupId]["memberCount"] = $memberCount;
}
}
}
}
return $this->success;
@ -162,7 +152,7 @@ namespace Core\API\Groups {
public function _execute(): bool {
$id = $this->getParam("id");
if (in_array($id, DEFAULT_GROUPS)) {
if (in_array($id, array_keys(Group::GROUPS))) {
return $this->createError("You cannot delete a default group.");
}

@ -47,7 +47,8 @@ namespace Core\API\Logs {
$shownLogLevels = array_slice(Logger::LOG_LEVELS, $logLevel);
}
$query = SystemLog::findAllBuilder($sql)
$query = SystemLog::createBuilder($sql, false)
->orderBy("timestamp")
->descending();
@ -59,7 +60,7 @@ namespace Core\API\Logs {
$query->where(new CondIn(new Column("severity"), $shownLogLevels));
}
$logEntries = $query->execute();
$logEntries = SystemLog::findBy($query);
$this->success = $logEntries !== false;
$this->lastError = $sql->getLastError();

@ -46,6 +46,7 @@ namespace Core\API\Mail {
use Core\API\MailAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\Objects\DatabaseEntity\MailQueueItem;
use DateTimeInterface;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
@ -84,7 +85,7 @@ namespace Core\API\Mail {
class Send extends MailAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'to' => new Parameter('to', Parameter::TYPE_EMAIL, true, null),
'to' => new Parameter('to', Parameter::TYPE_EMAIL),
'subject' => new StringType('subject', -1),
'body' => new StringType('body', -1),
'replyTo' => new Parameter('replyTo', Parameter::TYPE_EMAIL, true, null),
@ -104,7 +105,7 @@ namespace Core\API\Mail {
$fromMail = $mailConfig->getProperty('from');
$mailFooter = $mailConfig->getProperty('mail_footer');
$toMail = $this->getParam('to') ?? $fromMail;
$toMail = $this->getParam('to');
$subject = $this->getParam('subject');
$replyTo = $this->getParam('replyTo');
$replyName = $this->getParam('replyName');
@ -119,10 +120,8 @@ namespace Core\API\Mail {
if ($mailAsync) {
$sql = $this->context->getSQL();
$this->success = $sql->insert("MailQueue", ["from", "to", "subject", "body",
"replyTo", "replyName", "gpgFingerprint"])
->addRow($fromMail, $toMail, $subject, $body, $replyTo, $replyName, $gpgFingerprint)
->execute() !== false;
$mailQueueItem = new MailQueueItem($fromMail, $toMail, $subject, $body, $replyTo, $replyName, $gpgFingerprint);
$this->success = $mailQueueItem->save($sql);
$this->lastError = $sql->getLastError();
return $this->success;
}
@ -223,77 +222,37 @@ namespace Core\API\Mail {
}
$sql = $this->context->getSQL();
$res = $sql->select("id", "from", "to", "subject", "body",
"replyTo", "replyName", "gpgFingerprint", "retryCount")
->from("MailQueue")
$mailQueueItems = MailQueueItem::findBy(MailQueueItem::createBuilder($sql, false)
->where(new Compare("retryCount", 0, ">"))
->where(new Compare("status", "waiting"))
->where(new Compare("nextTry", $sql->now(), "<="))
->execute();
->where(new Compare("nextTry", $sql->now(), "<=")));
$this->success = ($res !== false);
$this->success = ($mailQueueItems !== false);
$this->lastError = $sql->getLastError();
if ($this->success && is_array($res)) {
if ($this->success && is_array($mailQueueItems)) {
if ($debug) {
echo "Found " . count($res) . " mails to send" . PHP_EOL;
echo "Found " . count($mailQueueItems) . " mails to send" . PHP_EOL;
}
$successfulMails = [];
foreach ($res as $row) {
$successfulMails = 0;
foreach ($mailQueueItems as $mailQueueItem) {
if (time() - $startTime >= 45) {
$this->lastError = "Not able to process whole mail queue within 45 seconds, will continue on next time";
break;
}
$to = $row["to"];
$subject = $row["subject"];
if ($debug) {
echo "Sending subject=$subject to=$to" . PHP_EOL;
echo "Sending subject=$mailQueueItem->subject to=$mailQueueItem->to" . PHP_EOL;
}
$mailId = intval($row["id"]);
$retryCount = intval($row["retryCount"]);
$req = new Send($this->context);
$args = [
"to" => $to,
"subject" => $subject,
"body" => $row["body"],
"replyTo" => $row["replyTo"],
"replyName" => $row["replyName"],
"gpgFingerprint" => $row["gpgFingerprint"],
"async" => false
];
$success = $req->execute($args);
$error = $req->getLastError();
if (!$success) {
$delay = [0, 720, 360, 60, 30, 1];
$minutes = $delay[max(0, min(count($delay) - 1, $retryCount))];
$nextTry = (new \DateTime())->modify("+$minutes minute");
$sql->update("MailQueue")
->set("retryCount", $retryCount - 1)
->set("status", "error")
->set("errorMessage", $error)
->set("nextTry", $nextTry)
->where(new Compare("id", $mailId))
->execute();
} else {
$successfulMails[] = $mailId;
if ($mailQueueItem->send($this->context)) {
$successfulMails++;
}
}
$this->success = count($successfulMails) === count($res);
if (!empty($successfulMails)) {
$res = $sql->update("MailQueue")
->set("status", "success")
->where(new CondIn(new Column("id"), $successfulMails))
->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
}
$this->success = $successfulMails === count($mailQueueItems);
}
return $this->success;

@ -19,6 +19,7 @@ namespace Core\API\News {
use Core\API\Parameter\StringType;
use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\News;
class Get extends NewsAPI {
@ -40,7 +41,7 @@ namespace Core\API\News {
}
$sql = $this->context->getSQL();
$newsQuery = News::findAllBuilder($sql)
$newsQuery = News::createBuilder($sql, false)
->limit($limit)
->orderBy("published_at")
->descending()
@ -50,7 +51,7 @@ namespace Core\API\News {
$newsQuery->where(new Compare("published_at", $since, ">="));
}
$newsArray = $newsQuery->execute();
$newsArray = News::findBy($newsQuery);
$this->success = $newsArray !== null;
$this->lastError = $sql->getLastError();
@ -113,7 +114,7 @@ namespace Core\API\News {
return false;
} else if ($news === null) {
return $this->createError("News Post not found");
} else if ($news->publishedBy->getId() !== $currentUser->getId() && !$currentUser->hasGroup(USER_GROUP_ADMIN)) {
} else if ($news->publishedBy->getId() !== $currentUser->getId() && !$currentUser->hasGroup(Group::ADMIN)) {
return $this->createError("You do not have permissions to delete news post of other users.");
}
@ -144,7 +145,7 @@ namespace Core\API\News {
return false;
} else if ($news === null) {
return $this->createError("News Post not found");
} else if ($news->publishedBy->getId() !== $currentUser->getId() && !$currentUser->hasGroup(USER_GROUP_ADMIN)) {
} else if ($news->publishedBy->getId() !== $currentUser->getId() && !$currentUser->hasGroup(Group::ADMIN)) {
return $this->createError("You do not have permissions to edit news post of other users.");
}

@ -1,231 +0,0 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class NotificationsAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
}
}
}
namespace Core\API\Notifications {
use Core\API\NotificationsAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Query\Select;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Notification;
use Core\Objects\DatabaseEntity\User;
class Create extends NotificationsAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'groupId' => new Parameter('groupId', Parameter::TYPE_INT, true),
'userId' => new Parameter('userId', Parameter::TYPE_INT, true),
'title' => new StringType('title', 32),
'message' => new StringType('message', 256),
));
$this->isPublic = false;
}
private function insertUserNotification($userId, $notificationId): bool {
$sql = $this->context->getSQL();
$res = $sql->insert("UserNotification", array("user_id", "notification_id"))
->addRow($userId, $notificationId)
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success;
}
private function insertGroupNotification($groupId, $notificationId): bool {
$sql = $this->context->getSQL();
$res = $sql->insert("GroupNotification", array("group_id", "notification_id"))
->addRow($groupId, $notificationId)
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success;
}
private function createNotification($title, $message): bool|int {
$sql = $this->context->getSQL();
$notification = new Notification();
$notification->title = $title;
$notification->message = $message;
$this->success = ($notification->save($sql) !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
return $notification->getId();
}
return $this->success;
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$userId = $this->getParam("userId");
$groupId = $this->getParam("groupId");
$title = $this->getParam("title");
$message = $this->getParam("message");
if (is_null($userId) && is_null($groupId)) {
return $this->createError("Either userId or groupId must be specified.");
} else if(!is_null($userId) && !is_null($groupId)) {
return $this->createError("Only one of userId and groupId must be specified.");
} else if(!is_null($userId)) {
if (User::exists($sql, $userId)) {
$id = $this->createNotification($title, $message);
if ($this->success) {
return $this->insertUserNotification($userId, $id);
}
} else {
return $this->createError("User not found: $userId");
}
} else if(!is_null($groupId)) {
if (Group::exists($sql, $groupId)) {
$id = $this->createNotification($title, $message);
if ($this->success) {
return $this->insertGroupNotification($groupId, $id);
}
} else {
return $this->createError("Group not found: $groupId");
}
}
return $this->success;
}
}
class Fetch extends NotificationsAPI {
private array $notifications;
private array $notificationIds;
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'new' => new Parameter('new', Parameter::TYPE_BOOLEAN, true, true)
));
$this->loginRequired = true;
}
private function fetchUserNotifications(): bool {
$onlyNew = $this->getParam('new');
$userId = $this->context->getUser()->getId();
$sql = $this->context->getSQL();
$query = $sql->select($sql->distinct("Notification.id"), "created_at", "title", "message", "type")
->from("Notification")
->innerJoin("UserNotification", "UserNotification.notification_id", "Notification.id")
->where(new Compare("UserNotification.user_id", $userId))
->orderBy("created_at")->descending();
if ($onlyNew) {
$query->where(new Compare("UserNotification.seen", false));
}
return $this->fetchNotifications($query);
}
private function fetchGroupNotifications(): bool {
$onlyNew = $this->getParam('new');
$userId = $this->context->getUser()->getId();
$sql = $this->context->getSQL();
$query = $sql->select($sql->distinct("Notification.id"), "created_at", "title", "message", "type")
->from("Notification")
->innerJoin("GroupNotification", "GroupNotification.notification_id", "Notification.id")
->innerJoin("UserGroup", "GroupNotification.group_id", "UserGroup.group_id")
->where(new Compare("UserGroup.user_id", $userId))
->orderBy("created_at")->descending();
if ($onlyNew) {
$query->where(new Compare("GroupNotification.seen", false));
}
return $this->fetchNotifications($query);
}
private function fetchNotifications(Select $query): bool {
$sql = $this->context->getSQL();
$res = $query->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
foreach($res as $row) {
$id = $row["id"];
if (!in_array($id, $this->notificationIds)) {
$this->notificationIds[] = $id;
$this->notifications[] = array(
"id" => $id,
"title" => $row["title"],
"message" => $row["message"],
"created_at" => $row["created_at"],
"type" => $row["type"]
);
}
}
}
return $this->success;
}
public function _execute(): bool {
$this->notifications = array();
$this->notificationIds = array();
if ($this->fetchUserNotifications() && $this->fetchGroupNotifications()) {
$this->result["notifications"] = $this->notifications;
}
return $this->success;
}
}
class Seen extends NotificationsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array());
$this->loginRequired = true;
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$sql = $this->context->getSQL();
$res = $sql->update("UserNotification")
->set("seen", true)
->where(new Compare("user_id", $currentUser->getId()))
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
$res = $sql->update("GroupNotification")
->set("seen", true)
->where(new CondIn(new Column("group_id"),
$sql->select("group_id")
->from("UserGroup")
->where(new Compare("user_id", $currentUser->getId()))))
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
}
return $this->success;
}
}
}

@ -3,6 +3,7 @@
namespace Core\API {
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
abstract class PermissionAPI extends Request {
@ -12,7 +13,7 @@ namespace Core\API {
protected function checkStaticPermission(): bool {
$user = $this->context->getUser();
if (!$user || !$user->hasGroup(USER_GROUP_ADMIN)) {
if (!$user || !$user->hasGroup(Group::ADMIN)) {
return $this->createError("Permission denied.");
}
@ -34,7 +35,6 @@ namespace Core\API\Permission {
use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\User;
class Check extends PermissionAPI {

@ -4,6 +4,7 @@ namespace Core\API;
use Core\API\Parameter\StringType;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\User;
class Swagger extends Request {
@ -90,7 +91,7 @@ class Swagger extends Request {
}
// special case: hardcoded permission
if ($request instanceof Permission\Save && (!$currentUser || !$currentUser->hasGroup(USER_GROUP_ADMIN))) {
if ($request instanceof Permission\Save && (!$currentUser || !$currentUser->hasGroup(Group::ADMIN))) {
return false;
}

@ -75,7 +75,7 @@ namespace Core\API {
$this->checkPasswordRequirements($password, $confirmPassword);
}
protected function insertUser($username, $email, $password, $confirmed, $fullName = ""): bool|User {
protected function insertUser(string $username, ?string $email, string $password, bool $confirmed, string $fullName = "", array $groups = []): bool|User {
$sql = $this->context->getSQL();
$user = new User();
@ -86,6 +86,7 @@ namespace Core\API {
$user->email = $email;
$user->confirmed = $confirmed;
$user->fullName = $fullName ?? "";
$user->groups = $groups;
$this->success = ($user->save($sql) !== FALSE);
$this->lastError = $sql->getLastError();
@ -107,12 +108,11 @@ namespace Core\API {
protected function checkToken(string $token) : UserToken|bool {
$sql = $this->context->getSQL();
$userToken = UserToken::findBuilder($sql)
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->where(new Compare("UserToken.token", $token))
->where(new Compare("UserToken.valid_until", $sql->now(), ">"))
->where(new Compare("UserToken.used", 0))
->fetchEntities()
->execute();
->fetchEntities());
if ($userToken === false) {
return $this->createError("Error verifying token: " . $sql->getLastError());
@ -128,11 +128,16 @@ namespace Core\API {
namespace Core\API\User {
use Core\API\Parameter\ArrayType;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\Template\Render;
use Core\API\UserAPI;
use Core\API\VerifyCaptcha;
use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Condition\CondNot;
use Core\Driver\SQL\Condition\CondOr;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\UserToken;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
@ -146,12 +151,15 @@ namespace Core\API\User {
class Create extends UserAPI {
private User $user;
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'username' => new StringType('username', 32),
'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL),
'password' => new StringType('password'),
'confirmPassword' => new StringType('confirmPassword'),
'groups' => new ArrayType("groups", Parameter::TYPE_INT, true, true, [])
));
$this->loginRequired = true;
@ -171,21 +179,36 @@ namespace Core\API\User {
return false;
}
$groups = [];
$sql = $this->context->getSQL();
$requestedGroups = array_unique($this->getParam("groups"));
if (!empty($requestedGroups)) {
$groups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups));
foreach ($requestedGroups as $groupId) {
if (!isset($groups[$groupId])) {
return $this->createError("Group with id=$groupId does not exist.");
}
}
}
// prevent duplicate keys
$email = (!is_null($email) && empty($email)) ? null : $email;
$user = $this->insertUser($username, $email, $password, true);
$user = $this->insertUser($username, $email, $password, true, "", $groups);
if ($user !== false) {
$this->user = $user;
$this->result["userId"] = $user->getId();
}
return $this->success;
}
public function getUser(): User {
return $this->user;
}
}
class Fetch extends UserAPI {
private int $userCount;
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'page' => new Parameter('page', Parameter::TYPE_INT, true, 1),
@ -193,21 +216,7 @@ namespace Core\API\User {
));
}
private function getUserCount(): bool {
$sql = $this->context->getSQL();
$res = $sql->select($sql->count())->from("User")->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->userCount = $res[0]["count"];
}
return $this->success;
}
private function selectIds($page, $count) {
private function selectIds($page, $count): array|bool {
$sql = $this->context->getSQL();
$res = $sql->select("User.id")
->from("User")
@ -241,72 +250,57 @@ namespace Core\API\User {
return $this->createError("Invalid fetch count");
}
if (!$this->getUserCount()) {
return false;
}
$userIds = $this->selectIds($page, $count);
if ($userIds === false) {
return false;
$condition = null;
$currentUser = $this->context->getUser();
$fullInfo = ($currentUser->hasGroup(Group::ADMIN) ||
$currentUser->hasGroup(Group::SUPPORT));
if (!$fullInfo) {
$condition = new CondOr(
new Compare("User.id", $currentUser->getId()),
new CondBool("User.confirmed")
);
}
$sql = $this->context->getSQL();
$res = $sql->select("User.id as userId", "User.name", "User.email", "User.registered_at", "User.confirmed",
"User.profile_picture", "User.full_name", "Group.id as groupId", "User.last_online",
"Group.name as groupName", "Group.color as groupColor")
->from("User")
->leftJoin("UserGroup", "User.id", "UserGroup.user_id")
->leftJoin("Group", "Group.id", "UserGroup.group_id")
->where(new CondIn(new Column("User.id"), $userIds))
->execute();
$userCount = User::count($sql, $condition);
if ($userCount === false) {
return $this->createError("Error fetching user count: " . $sql->getLastError());
}
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
$currentUser = $this->context->getUser();
$userQuery = User::createBuilder($sql, false)
->orderBy("id")
->ascending()
->limit($count)
->offset(($page - 1) * $count)
->fetchEntities();
if ($this->success) {
$this->result["users"] = array();
foreach ($res as $row) {
$userId = intval($row["userId"]);
$groupId = $row["groupId"];
$groupName = $row["groupName"];
$groupColor = $row["groupColor"];
if ($condition) {
$userQuery->where($condition);
}
$fullInfo = ($userId === $currentUser->getId() ||
$currentUser->hasGroup(USER_GROUP_ADMIN) ||
$currentUser->hasGroup(USER_GROUP_SUPPORT));
$users = User::findBy($userQuery);
if ($users !== false) {
$this->result["users"] = [];
if (!isset($this->result["users"][$userId])) {
$user = array(
"id" => $userId,
"name" => $row["name"],
"fullName" => $row["full_name"],
"profilePicture" => $row["profile_picture"],
"email" => $row["email"],
"confirmed" => $sql->parseBool($row["confirmed"]),
"groups" => array(),
);
foreach ($users as $userId => $user) {
$serialized = $user->jsonSerialize();
if ($fullInfo) {
$user["registered_at"] = $row["registered_at"];
$user["last_online"] = $row["last_online"];
} else if (!$sql->parseBool($row["confirmed"])) {
continue;
if (!$fullInfo && $userId !== $currentUser->getId()) {
$publicAttributes = ["id", "name", "fullName", "profilePicture", "email", "groups"];
foreach (array_keys($serialized) as $attr) {
if (!in_array($attr, $publicAttributes)) {
unset($serialized[$attr]);
}
}
$this->result["users"][$userId] = $user;
}
if (!is_null($groupId)) {
$this->result["users"][$userId]["groups"][intval($groupId)] = array(
"id" => intval($groupId),
"name" => $groupName,
"color" => $groupColor
);
}
$this->result["users"][$userId] = $serialized;
}
$this->result["pageCount"] = intval(ceil($this->userCount / $count));
$this->result["totalCount"] = $this->userCount;
} else {
return $this->createError("Error fetching users: " . $sql->getLastError());
}
return $this->success;
@ -338,13 +332,13 @@ namespace Core\API\User {
// either we are querying own info or we are support / admin
$currentUser = $this->context->getUser();
$canView = ($userId === $currentUser->getId() ||
$currentUser->hasGroup(USER_GROUP_ADMIN) ||
$currentUser->hasGroup(USER_GROUP_SUPPORT));
$currentUser->hasGroup(Group::ADMIN) ||
$currentUser->hasGroup(Group::SUPPORT));
// full info only when we have administrative privileges, or we are querying ourselves
$fullInfo = ($userId === $currentUser->getId() ||
$currentUser->hasGroup(USER_GROUP_ADMIN) ||
$currentUser->hasGroup(USER_GROUP_SUPPORT));
$currentUser->hasGroup(Group::ADMIN) ||
$currentUser->hasGroup(Group::SUPPORT));
if (!$canView) {
@ -617,10 +611,9 @@ namespace Core\API\User {
$stayLoggedIn = $this->getParam('stayLoggedIn');
$sql = $this->context->getSQL();
$user = User::findBuilder($sql)
$user = User::findBy(User::createBuilder($sql, true)
->where(new Compare("User.name", $username), new Compare("User.email", $username))
->fetchEntities()
->execute();
->fetchEntities());
if ($user !== false) {
if ($user === null) {
@ -842,7 +835,7 @@ namespace Core\API\User {
$groupIds[] = $param->value;
}
if ($id === $currentUser->getId() && !in_array(USER_GROUP_ADMIN, $groupIds)) {
if ($id === $currentUser->getId() && !in_array(Group::ADMIN, $groupIds)) {
return $this->createError("Cannot remove Administrator group from own user.");
}
}
@ -958,10 +951,9 @@ namespace Core\API\User {
$sql = $this->context->getSQL();
$email = $this->getParam("email");
$user = User::findBuilder($sql)
$user = User::findBy(User::createBuilder($sql, true)
->where(new Compare("email", $email))
->fetchEntities()
->execute();
->fetchEntities());
if ($user === false) {
return $this->createError("Could not fetch user details: " . $sql->getLastError());
} else if ($user !== null) {
@ -1040,10 +1032,9 @@ namespace Core\API\User {
$email = $this->getParam("email");
$sql = $this->context->getSQL();
$user = User::findBuilder($sql)
$user = User::findBy(User::createBuilder($sql, true)
->where(new Compare("User.email", $email))
->where(new Compare("User.confirmed", false))
->execute();
->where(new Compare("User.confirmed", false)));
if ($user === false) {
return $this->createError("Error retrieving user details: " . $sql->getLastError());
@ -1052,11 +1043,10 @@ namespace Core\API\User {
return true;
}
$userToken = UserToken::findBuilder($sql)
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->where(new Compare("used", false))
->where(new Compare("tokenType", UserToken::TYPE_EMAIL_CONFIRM))
->where(new Compare("user_id", $user->getId()))
->execute();
->where(new Compare("user_id", $user->getId())));
$validHours = 48;
if ($userToken === false) {
@ -1366,12 +1356,11 @@ namespace Core\API\User {
$token = $this->getParam("token");
$sql = $this->context->getSQL();
$userToken = UserToken::findBuilder($sql)
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->where(new Compare("token", $token))
->where(new Compare("valid_until", $sql->now(), ">="))
->where(new Compare("user_id", $currentUser->getId()))
->where(new Compare("token_type", UserToken::TYPE_GPG_CONFIRM))
->execute();
->where(new Compare("token_type", UserToken::TYPE_GPG_CONFIRM)));
if ($userToken !== false) {
if ($userToken === null) {

@ -3,9 +3,9 @@
namespace Core\Configuration;
use Core\Driver\SQL\SQL;
use Core\Driver\SQL\Strategy\SetNullStrategy;
use Core\Driver\SQL\Strategy\CascadeStrategy;
use Core\Objects\DatabaseEntity\DatabaseEntity;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Language;
use PHPUnit\Util\Exception;
class CreateDatabase extends DatabaseScript {
@ -15,37 +15,16 @@ class CreateDatabase extends DatabaseScript {
self::loadEntities($queries, $sql);
$queries[] = $sql->insert("Language", array("code", "name"))
->addRow("en_US", 'American English')
->addRow("de_DE", 'Deutsch Standard');
$queries[] = Language::getHandler($sql)->getInsertQuery([
new Language(Language::AMERICAN_ENGLISH, "en_US", 'American English'),
new Language(Language::AMERICAN_ENGLISH, "de_DE", 'Deutsch Standard'),
]);
$queries[] = $sql->insert("Group", array("name", "color"))
->addRow(USER_GROUP_MODERATOR_NAME, "#007bff")
->addRow(USER_GROUP_SUPPORT_NAME, "#28a745")
->addRow(USER_GROUP_ADMIN_NAME, "#dc3545");
$queries[] = $sql->createTable("UserGroup")
->addInt("user_id")
->addInt("group_id")
->unique("user_id", "group_id")
->foreignKey("user_id", "User", "id", new CascadeStrategy())
->foreignKey("group_id", "Group", "id", new CascadeStrategy());
$queries[] = $sql->createTable("UserNotification")
->addInt("user_id")
->addInt("notification_id")
->addBool("seen", false)
->foreignKey("user_id", "User", "id")
->foreignKey("notification_id", "Notification", "id")
->unique("user_id", "notification_id");
$queries[] = $sql->createTable("GroupNotification")
->addInt("group_id")
->addInt("notification_id")
->addBool("seen", false)
->foreignKey("group_id", "Group", "id")
->foreignKey("notification_id", "Notification", "id")
->unique("group_id", "notification_id");
$queries[] = Group::getHandler($sql)->getInsertQuery([
new Group(Group::ADMIN, Group::GROUPS[Group::ADMIN], "#007bff"),
new Group(Group::MODERATOR, Group::GROUPS[Group::MODERATOR], "#28a745"),
new Group(Group::SUPPORT, Group::GROUPS[Group::SUPPORT], "#dc3545"),
]);
$queries[] = $sql->createTable("Visitor")
->addInt("day")
@ -81,92 +60,43 @@ class CreateDatabase extends DatabaseScript {
->addBool("private", false) // these values are not returned from '/api/settings/get', but can be changed
->addBool("readonly", false) // these values are neither returned, nor can be changed from outside
->primaryKey("name");
$settingsQuery = $sql->insert("Settings", array("name", "value", "private", "readonly"))
// ->addRow("mail_enabled", "0") # this key will be set during installation
->addRow("mail_host", "", false, false)
->addRow("mail_port", "", false, false)
->addRow("mail_username", "", false, false)
->addRow("mail_password", "", true, false)
->addRow("mail_from", "", false, false)
->addRow("mail_last_sync", "", false, false)
->addRow("mail_footer", "", false, false);
$settingsQuery = $sql->insert("Settings", array("name", "value", "private", "readonly"));
(Settings::loadDefaults())->addRows($settingsQuery);
$queries[] = $settingsQuery;
$queries[] = $sql->createTable("ContactRequest")
->addSerial("id")
->addString("from_name", 32)
->addString("from_email", 64)
->addString("message", 512)
->addString("messageId", 78, true) # null = don't sync with mails (usually if mail could not be sent)
->addDateTime("created_at", false, $sql->currentTimestamp())
->unique("messageId")
->primaryKey("id");
$queries[] = $sql->createTable("ContactMessage")
->addSerial("id")
->addInt("request_id")
->addInt("user_id", true) # null = customer has sent this message
->addString("message", 512)
->addString("messageId", 78)
->addDateTime("created_at", false, $sql->currentTimestamp())
->addBool("read", false)
->unique("messageId")
->primaryKey("id")
->foreignKey("request_id", "ContactRequest", "id", new CascadeStrategy())
->foreignKey("user_id", "User", "id", new SetNullStrategy());
$queries[] = $sql->createTable("ApiPermission")
->addString("method", 32)
->addJson("groups", true, '[]')
->addString("description", 128, false, "")
->primaryKey("method");
$queries[] = $sql->createTable("MailQueue")
->addSerial("id")
->addString("from", 64)
->addString("to", 64)
->addString("subject")
->addString("body")
->addString("replyTo", 64, true)
->addString("replyName", 32, true)
->addString("gpgFingerprint", 64, true)
->addEnum("status", ["waiting", "success", "error"], false, 'waiting')
->addInt("retryCount", false, 5)
->addDateTime("nextTry", false, $sql->now())
->addString("errorMessage", NULL, true)
->primaryKey("id");
$queries = array_merge($queries, \Core\Configuration\Patch\EntityLog_2021_04_08::createTableLog($sql, "MailQueue", 30));
$queries[] = $sql->insert("ApiPermission", array("method", "groups", "description"))
->addRow("ApiKey/create", array(), "Allows users to create API-Keys for themselves")
->addRow("ApiKey/fetch", array(), "Allows users to list their API-Keys")
->addRow("ApiKey/refresh", array(), "Allows users to refresh their API-Keys")
->addRow("ApiKey/revoke", array(), "Allows users to revoke their API-Keys")
->addRow("Groups/fetch", array(USER_GROUP_SUPPORT, USER_GROUP_ADMIN), "Allows users to list all available groups")
->addRow("Groups/create", array(USER_GROUP_ADMIN), "Allows users to create a new groups")
->addRow("Groups/delete", array(USER_GROUP_ADMIN), "Allows users to delete a group")
->addRow("Routes/fetch", array(USER_GROUP_ADMIN), "Allows users to list all configured routes")
->addRow("Routes/save", array(USER_GROUP_ADMIN), "Allows users to create, delete and modify routes")
->addRow("Mail/test", array(USER_GROUP_SUPPORT, USER_GROUP_ADMIN), "Allows users to send a test email to a given address")
->addRow("Mail/Sync", array(USER_GROUP_SUPPORT, USER_GROUP_ADMIN), "Allows users to synchronize mails with the database")
->addRow("Settings/get", array(USER_GROUP_ADMIN), "Allows users to fetch server settings")
->addRow("Settings/set", array(USER_GROUP_ADMIN), "Allows users create, delete or modify server settings")
->addRow("Stats", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to fetch server stats")
->addRow("User/create", array(USER_GROUP_ADMIN), "Allows users to create a new user, email address does not need to be confirmed")
->addRow("User/fetch", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to list all registered users")
->addRow("User/get", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to get information about a single user")
->addRow("User/invite", array(USER_GROUP_ADMIN), "Allows users to create a new user and send them an invitation link")
->addRow("User/edit", array(USER_GROUP_ADMIN), "Allows users to edit details and group memberships of any user")
->addRow("User/delete", array(USER_GROUP_ADMIN), "Allows users to delete any other user")
->addRow("Permission/fetch", array(USER_GROUP_ADMIN), "Allows users to list all API permissions")
->addRow("Visitors/stats", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to see visitor statistics")
->addRow("Contact/respond", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to respond to contact requests")
->addRow("Contact/fetch", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to fetch all contact requests")
->addRow("Contact/get", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to see messages within a contact request");
->addRow("Groups/fetch", array(Group::SUPPORT, Group::ADMIN), "Allows users to list all available groups")
->addRow("Groups/create", array(Group::ADMIN), "Allows users to create a new groups")
->addRow("Groups/delete", array(Group::ADMIN), "Allows users to delete a group")
->addRow("Routes/fetch", array(Group::ADMIN), "Allows users to list all configured routes")
->addRow("Routes/save", array(Group::ADMIN), "Allows users to create, delete and modify routes")
->addRow("Mail/test", array(Group::SUPPORT, Group::ADMIN), "Allows users to send a test email to a given address")
->addRow("Mail/Sync", array(Group::SUPPORT, Group::ADMIN), "Allows users to synchronize mails with the database")
->addRow("Settings/get", array(Group::ADMIN), "Allows users to fetch server settings")
->addRow("Settings/set", array(Group::ADMIN), "Allows users create, delete or modify server settings")
->addRow("Stats", array(Group::ADMIN, Group::SUPPORT), "Allows users to fetch server stats")
->addRow("User/create", array(Group::ADMIN), "Allows users to create a new user, email address does not need to be confirmed")
->addRow("User/fetch", array(Group::ADMIN, Group::SUPPORT), "Allows users to list all registered users")
->addRow("User/get", array(Group::ADMIN, Group::SUPPORT), "Allows users to get information about a single user")
->addRow("User/invite", array(Group::ADMIN), "Allows users to create a new user and send them an invitation link")
->addRow("User/edit", array(Group::ADMIN), "Allows users to edit details and group memberships of any user")
->addRow("User/delete", array(Group::ADMIN), "Allows users to delete any other user")
->addRow("Permission/fetch", array(Group::ADMIN), "Allows users to list all API permissions")
->addRow("Visitors/stats", array(Group::ADMIN, Group::SUPPORT), "Allows users to see visitor statistics")
->addRow("Contact/respond", array(Group::ADMIN, Group::SUPPORT), "Allows users to respond to contact requests")
->addRow("Contact/fetch", array(Group::ADMIN, Group::SUPPORT), "Allows users to fetch all contact requests")
->addRow("Contact/get", array(Group::ADMIN, Group::SUPPORT), "Allows users to see messages within a contact request")
->addRow("Logs/get", [Group::ADMIN], "Allows users to fetch system logs");
self::loadPatches($queries, $sql);
@ -195,9 +125,10 @@ class CreateDatabase extends DatabaseScript {
}
public static function loadEntities(&$queries, $sql) {
$handlers = [];
$persistables = [];
$baseDirs = ["Core", "Site"];
foreach ($baseDirs as $baseDir) {
$entityDirectory = "./$baseDir/Objects/DatabaseEntity/";
if (file_exists($entityDirectory) && is_dir($entityDirectory)) {
$scan_arr = scandir($entityDirectory);
@ -206,38 +137,40 @@ class CreateDatabase extends DatabaseScript {
$suffix = ".class.php";
if (endsWith($file, $suffix)) {
$className = substr($file, 0, strlen($file) - strlen($suffix));
if (!in_array($className, ["DatabaseEntity", "DatabaseEntityQuery", "DatabaseEntityHandler"])) {
$className = "\\$baseDir\\Objects\\DatabaseEntity\\$className";
$reflectionClass = new \ReflectionClass($className);
if ($reflectionClass->isSubclassOf(DatabaseEntity::class)) {
$method = "$className::getHandler";
$handler = call_user_func($method, $sql);
$handlers[$handler->getTableName()] = $handler;
$className = "\\$baseDir\\Objects\\DatabaseEntity\\$className";
$reflectionClass = new \ReflectionClass($className);
if ($reflectionClass->isSubclassOf(DatabaseEntity::class)) {
$method = "$className::getHandler";
$handler = call_user_func($method, $sql);
$persistables[$handler->getTableName()] = $handler;
foreach ($handler->getNMRelations() as $nmTableName => $nmRelation) {
$persistables[$nmTableName] = $nmRelation;
}
}
}
}
}
$tableCount = count($handlers);
$tableCount = count($persistables);
$createdTables = [];
while (!empty($handlers)) {
while (!empty($persistables)) {
$prevCount = $tableCount;
$unmetDependenciesTotal = [];
foreach ($handlers as $tableName => $handler) {
$dependsOn = $handler->dependsOn();
foreach ($persistables as $tableName => $persistable) {
$dependsOn = $persistable->dependsOn();
$unmetDependencies = array_diff($dependsOn, $createdTables);
if (empty($unmetDependencies)) {
$queries[] = $handler->getTableQuery();
$queries = array_merge($queries, $persistable->getCreateQueries($sql));
$createdTables[] = $tableName;
unset($handlers[$tableName]);
unset($persistables[$tableName]);
} else {
$unmetDependenciesTotal = array_merge($unmetDependenciesTotal, $unmetDependencies);
}
}
$tableCount = count($handlers);
$tableCount = count($persistables);
if ($tableCount === $prevCount) {
throw new Exception("Circular or unmet table dependency detected. Unmet dependencies: "
. implode(", ", $unmetDependenciesTotal));

@ -5,7 +5,6 @@ namespace Core\Configuration\Patch;
use Core\Configuration\DatabaseScript;
use Core\Driver\SQL\Column\IntColumn;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Query\CreateProcedure;
use Core\Driver\SQL\SQL;
use Core\Driver\SQL\Type\CurrentColumn;
use Core\Driver\SQL\Type\CurrentTable;
@ -13,32 +12,6 @@ use Core\Driver\SQL\Type\Trigger;
class EntityLog_2021_04_08 extends DatabaseScript {
public static function createTableLog(SQL $sql, string $table, int $lifetime = 90): array {
return [
$sql->createTrigger("${table}_trg_insert")
->after()->insert($table)
->exec(new CreateProcedure($sql, "InsertEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"),
"lifetime" => $lifetime,
]),
$sql->createTrigger("${table}_trg_update")
->after()->update($table)
->exec(new CreateProcedure($sql, "UpdateEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"),
]),
$sql->createTrigger("${table}_trg_delete")
->after()->delete($table)
->exec(new CreateProcedure($sql, "DeleteEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"),
])
];
}
public static function createQueries(SQL $sql): array {
$queries = array();
@ -84,11 +57,6 @@ class EntityLog_2021_04_08 extends DatabaseScript {
$queries[] = $updateProcedure;
$queries[] = $deleteProcedure;
$tables = ["ContactRequest"];
foreach ($tables as $table) {
$queries = array_merge($queries, self::createTableLog($sql, $table));
}
return $queries;
}

@ -1,16 +0,0 @@
<?php
namespace Core\Configuration\Patch;
use Core\Configuration\DatabaseScript;
use Core\Driver\SQL\SQL;
class SystemLog_2022_03_30 extends DatabaseScript {
public static function createQueries(SQL $sql): array {
return [
$sql->insert("ApiPermission", ["method", "groups", "description"])
->addRow("Logs/get", [USER_GROUP_ADMIN], "Allows users to fetch system logs")
];
}
}

@ -63,7 +63,7 @@ class Settings {
}
public static function loadDefaults(): Settings {
$hostname = $_SERVER["SERVER_NAME"];
$hostname = $_SERVER["SERVER_NAME"] ?? null;
if (empty($hostname)) {
$hostname = "localhost";
}
@ -190,7 +190,14 @@ class Settings {
->addRow("recaptcha_enabled", $this->recaptchaEnabled ? "1" : "0", false, false)
->addRow("recaptcha_public_key", $this->recaptchaPublicKey, false, false)
->addRow("recaptcha_private_key", $this->recaptchaPrivateKey, true, false)
->addRow("allowed_extensions", implode(",", $this->allowedExtensions), true, false);
->addRow("allowed_extensions", implode(",", $this->allowedExtensions), true, false)
->addRow("mail_host", "", false, false)
->addRow("mail_port", "", false, false)
->addRow("mail_username", "", false, false)
->addRow("mail_password", "", true, false)
->addRow("mail_from", "", false, false)
->addRow("mail_last_sync", "", false, false)
->addRow("mail_footer", "", false, false);
}
public function getSiteName(): string {

@ -17,7 +17,7 @@ class Info extends HtmlDocument {
class InfoBody extends SimpleBody {
protected function getContent(): string {
$user = $this->getDocument()->getUser();
if ($user && $user->hasGroup(USER_GROUP_ADMIN)) {
if ($user && $user->hasGroup(Group::ADMIN)) {
phpinfo();
return "";
} else {

@ -30,6 +30,7 @@ namespace Documents\Install {
use Core\External\PHPMailer\Exception;
use Core\External\PHPMailer\PHPMailer;
use Core\Objects\ConnectionData;
use Core\Objects\DatabaseEntity\Group;
class InstallHead extends Head {
@ -202,7 +203,7 @@ namespace Documents\Install {
$req->execute(array(
"title" => "Welcome",
"message" => "Your Web-base was successfully installed. Check out the admin dashboard. Have fun!",
"groupId" => USER_GROUP_ADMIN
"groupId" => Group::ADMIN
)
);
$this->errorString = $req->getLastError();
@ -436,16 +437,10 @@ namespace Documents\Install {
'email' => $email,
'password' => $password,
'confirmPassword' => $confirmPassword,
'groups' => [Group::ADMIN]
));
$msg = $req->getLastError();
if ($success) {
$sql = $context->getSQL();
$success = $sql->insert("UserGroup", array("group_id", "user_id"))
->addRow(USER_GROUP_ADMIN, $req->getResult()["userId"])
->execute();
$msg = $sql->getLastError();
}
}
return array("msg" => $msg, "success" => $success);

@ -38,6 +38,11 @@ class Select extends Query {
$this->fetchType = SQL::FETCH_ALL;
}
public function addColumn(string $columnName): Select {
$this->selectValues[] = $columnName;
return $this;
}
public function from(...$tables): Select {
$this->tables = array_merge($this->tables, $tables);
return $this;

@ -90,7 +90,7 @@ class Context {
session_write_close();
}
private function loadSession(int $userId, int $sessionId) {
private function loadSession(int $userId, int $sessionId): void {
$this->session = Session::init($this, $userId, $sessionId);
$this->user = $this->session?->getUser();
if ($this->user) {
@ -128,12 +128,13 @@ class Context {
public function updateLanguage(string $lang): bool {
if ($this->sql) {
$language = Language::findBuilder($this->sql)
$language = Language::findBy(Language::createBuilder($this->sql, true)
->where(new CondOr(
new CondLike("name", "%$lang%"), // english
new Compare("code", $lang), // de_DE
new CondLike("code", $lang . "_%"))) // de -> de_%
->execute();
new CondLike("code", "${lang}_%") // de -> de_%
))
);
if ($language) {
$this->setLanguage($language);
return true;
@ -176,14 +177,13 @@ class Context {
}
public function loadApiKey(string $apiKey): bool {
$this->user = User::findBuilder($this->sql)
$this->user = User::findBy(User::createBuilder($this->sql, true)
->addJoin(new Join("INNER","ApiKey", "ApiKey.user_id", "User.id"))
->where(new Compare("ApiKey.api_key", $apiKey))
->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">"))
->where(new Compare("ApiKey.active", true))
->where(new Compare("User.confirmed", true))
->fetchEntities()
->execute();
->fetchEntities());
return $this->user !== null;
}

@ -3,6 +3,7 @@
namespace Core\Objects\DatabaseEntity;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class ApiKey extends DatabaseEntity {

@ -0,0 +1,18 @@
<?php
namespace Core\Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Multiple {
private string $className;
public function __construct(string $className) {
$this->className = $className;
}
public function getClassName(): string {
return $this->className;
}
}

@ -1,6 +1,6 @@
<?php
namespace Core\Objects\DatabaseEntity;
namespace Core\Objects\DatabaseEntity\Controller;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\Condition;
@ -8,6 +8,13 @@ use Core\Driver\SQL\SQL;
abstract class DatabaseEntity {
protected static array $entityLogConfig = [
"insert" => false,
"update" => false,
"delete" => false,
"lifetime" => null,
];
private static array $handlers = [];
protected ?int $id;
@ -51,8 +58,8 @@ abstract class DatabaseEntity {
return $res !== false && $res[0]["count"] !== 0;
}
public static function findBuilder(SQL $sql): DatabaseEntityQuery {
return DatabaseEntityQuery::fetchOne(self::getHandler($sql));
public static function findBy(DatabaseEntityQuery $dbQuery): static|array|bool|null {
return $dbQuery->execute();
}
public static function findAll(SQL $sql, ?Condition $condition = null): ?array {
@ -60,17 +67,22 @@ abstract class DatabaseEntity {
return $handler->fetchMultiple($condition);
}
public static function findAllBuilder(SQL $sql): DatabaseEntityQuery {
return DatabaseEntityQuery::fetchAll(self::getHandler($sql));
public static function createBuilder(SQL $sql, bool $one): DatabaseEntityQuery {
if ($one) {
return DatabaseEntityQuery::fetchOne(self::getHandler($sql));
} else {
return DatabaseEntityQuery::fetchAll(self::getHandler($sql));
}
}
public function save(SQL $sql, ?array $columns = null): bool {
public function save(SQL $sql, ?array $columns = null, bool $saveNM = false): bool {
$handler = self::getHandler($sql);
$res = $handler->insertOrUpdate($this, $columns);
$res = $handler->insertOrUpdate($this, $columns, $saveNM);
if ($res === false) {
return false;
} else if ($this->id === null) {
$this->id = $res;
$handler->insertNM($this);
}
return true;
@ -127,4 +139,22 @@ abstract class DatabaseEntity {
public function getId(): ?int {
return $this->id;
}
public static function count(SQL $sql, ?Condition $condition = null): int|bool {
$handler = self::getHandler($sql);
$query = $sql->select($sql->count())
->from($handler->getTableName());
if ($condition) {
$query->where($condition);
}
$res = $query->execute();
if (!empty($res)) {
return $res[0]["count"];
}
return false;
}
}

@ -1,33 +1,46 @@
<?php
namespace Core\Objects\DatabaseEntity;
namespace Core\Objects\DatabaseEntity\Controller;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Column\BoolColumn;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Column\DateTimeColumn;
use Core\Driver\SQL\Column\EnumColumn;
use Core\Driver\SQL\Column\IntColumn;
use Core\Driver\SQL\Column\JsonColumn;
use Core\Driver\SQL\Column\StringColumn;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondAnd;
use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Condition\Condition;
use Core\Driver\SQL\Column\DoubleColumn;
use Core\Driver\SQL\Column\FloatColumn;
use Core\Driver\SQL\Condition\CondNot;
use Core\Driver\SQL\Condition\CondOr;
use Core\Driver\SQL\Constraint\ForeignKey;
use Core\Driver\SQL\Join;
use Core\Driver\SQL\Query\CreateProcedure;
use Core\Driver\SQL\Query\CreateTable;
use Core\Driver\SQL\Query\Insert;
use Core\Driver\SQL\Query\Select;
use Core\Driver\SQL\SQL;
use Core\Driver\SQL\Strategy\CascadeStrategy;
use Core\Driver\SQL\Strategy\SetNullStrategy;
use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Driver\SQL\Type\CurrentColumn;
use Core\Driver\SQL\Type\CurrentTable;
use Core\Objects\DatabaseEntity\Attribute\Enum;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\Json;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\Multiple;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Attribute\Unique;
use PHPUnit\Util\Exception;
class DatabaseEntityHandler {
class DatabaseEntityHandler implements Persistable {
private \ReflectionClass $entityClass;
private string $tableName;
@ -35,6 +48,7 @@ class DatabaseEntityHandler {
private array $properties;
private array $relations;
private array $constraints;
private array $nmRelations;
private SQL $sql;
private Logger $logger;
@ -50,8 +64,9 @@ class DatabaseEntityHandler {
$this->tableName = $this->entityClass->getShortName();
$this->columns = []; // property name => database column name
$this->properties = []; // property name => \ReflectionProperty
$this->relations = []; // property name => referenced table name
$this->relations = []; // property name => DatabaseEntityHandler
$this->constraints = []; // \Driver\SQL\Constraint\Constraint
$this->nmRelations = []; // table name => NMRelation
foreach ($this->entityClass->getProperties() as $property) {
$propertyName = $property->getName();
@ -60,6 +75,10 @@ class DatabaseEntityHandler {
continue;
}
if ($property->isStatic()) {
continue;
}
$propertyType = $property->getType();
$columnName = self::getColumnName($propertyName);
if (!($propertyType instanceof \ReflectionNamedType)) {
@ -93,18 +112,46 @@ class DatabaseEntityHandler {
$this->columns[$propertyName] = new BoolColumn($columnName, $defaultValue ?? false);
} else if ($propertyTypeName === 'DateTime') {
$this->columns[$propertyName] = new DateTimeColumn($columnName, $nullable, $defaultValue);
/*} else if ($propertyName === 'array') {
$many = self::getAttribute($property, Many::class);
if ($many) {
$requestedType = $many->getValue();
if (isClass($requestedType)) {
$requestedClass = new \ReflectionClass($requestedType);
/*} else if ($propertyName === 'array') {
$many = self::getAttribute($property, Many::class);
if ($many) {
$requestedType = $many->getValue();
if (isClass($requestedType)) {
$requestedClass = new \ReflectionClass($requestedType);
} else {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $requestedType");
}
} else {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $requestedType");
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $propertyTypeName");
}*/
} else if ($propertyTypeName === "array") {
$multiple = self::getAttribute($property, Multiple::class);
if (!$multiple) {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $propertyTypeName. " .
"Is the 'Multiple' attribute missing?");
}
try {
$refClass = $multiple->getClassName();
$requestedClass = new \ReflectionClass($refClass);
if ($requestedClass->isSubclassOf(DatabaseEntity::class)) {
$nmTableName = NMRelation::buildTableName($this->getTableName(), $requestedClass->getShortName());
$nmRelation = $this->nmRelations[$nmTableName] ?? null;
if (!$nmRelation) {
$otherHandler = DatabaseEntity::getHandler($this->sql, $requestedClass);
$otherNM = $otherHandler->getNMRelations();
$nmRelation = $otherNM[$nmTableName] ?? (new NMRelation($this, $otherHandler));
$this->nmRelations[$nmTableName] = $nmRelation;
}
$this->nmRelations[$nmTableName]->addProperty($this, $property);
} else {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' of type multiple can " .
"only reference DatabaseEntity types, but got: $refClass");
}
} else {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $propertyTypeName");
}*/
} catch (\Exception $ex) {
$this->raiseError("Cannot persist class '$className' property '$propertyTypeName': " . $ex->getMessage());
}
} else if ($propertyTypeName !== "mixed") {
try {
$requestedClass = new \ReflectionClass($propertyTypeName);
@ -167,6 +214,10 @@ class DatabaseEntityHandler {
return $this->relations;
}
public function getNMRelations(): array {
return $this->nmRelations;
}
public function getColumnNames(): array {
$columns = ["$this->tableName.id"];
foreach ($this->columns as $column) {
@ -238,6 +289,14 @@ class DatabaseEntityHandler {
}
}
// init n:m / 1:n properties with empty arrays
foreach ($this->nmRelations as $nmRelation) {
foreach ($nmRelation->getProperties($this) as $property) {
$property->setAccessible(true);
$property->setValue($entity, []);
}
}
$this->properties["id"]->setAccessible(true);
$this->properties["id"]->setValue($entity, $row["id"]);
$entity->postFetch($this->sql, $row);
@ -248,6 +307,190 @@ class DatabaseEntityHandler {
}
}
public function updateNM(DatabaseEntity $entity): bool {
if (empty($this->nmRelations)) {
return true;
}
foreach ($this->nmRelations as $nmTable => $nmRelation) {
$thisIdColumn = $nmRelation->getIdColumn($this);
$thisTableName = $this->getTableName();
$dataColumns = $nmRelation->getDataColumns();
$otherHandler = $nmRelation->getOtherHandler($this);
$refIdColumn = $nmRelation->getIdColumn($otherHandler);
// delete from n:m table if no longer exists
$deleteStatement = $this->sql->delete($nmTable)
->where(new Compare($thisIdColumn, $entity->getId()));
if (!empty($dataColumns)) {
$conditions = [];
foreach ($dataColumns[$thisTableName] as $propertyName => $columnName) {
$property = $this->properties[$propertyName];
$entityIds = array_keys($property->getValue($entity));
if (!empty($entityIds)) {
$conditions[] = new CondAnd(
new CondBool($columnName),
new CondNot(new CondIn(new Column($refIdColumn), $entityIds)),
);
}
}
if (!empty($conditions)) {
$deleteStatement->where(new CondOr(...$conditions));
}
} else {
$property = next($nmRelation->getProperties($this));
$entityIds = array_keys($property->getValue($entity));
if (!empty($entityIds)) {
$deleteStatement->where(
new CondNot(new CondIn(new Column($refIdColumn), $entityIds))
);
}
}
$deleteStatement->execute();
}
return $this->insertNM($entity, true);
}
public function insertNM(DatabaseEntity $entity, bool $ignoreExisting = true): bool {
if (empty($this->nmRelations)) {
return true;
}
$success = true;
foreach ($this->nmRelations as $nmTable => $nmRelation) {
$otherHandler = $nmRelation->getOtherHandler($this);
$thisIdColumn = $nmRelation->getIdColumn($this);
$thisTableName = $this->getTableName();
$refIdColumn = $nmRelation->getIdColumn($otherHandler);
$dataColumns = $nmRelation->getDataColumns();
$columns = [
$thisIdColumn,
$refIdColumn,
];
if (!empty($dataColumns)) {
$columns = array_merge($columns, array_values($dataColumns[$thisTableName]));
}
$statement = $this->sql->insert($nmTable, $columns);
if ($ignoreExisting) {
$statement->onDuplicateKeyStrategy(new UpdateStrategy($nmRelation->getAllColumns(), [
$thisIdColumn => $entity->getId()
]));
}
foreach ($nmRelation->getProperties($this) as $property) {
$property->setAccessible(true);
$relEntities = $property->getValue($entity);
foreach ($relEntities as $relEntity) {
$nmRow = [$entity->getId(), $relEntity->getId()];
if (!empty($dataColumns)) {
foreach (array_keys($dataColumns[$thisTableName]) as $propertyName) {
$nmRow[] = $property->getName() === $propertyName;
}
}
$statement->addRow(...$nmRow);
}
}
$success = $statement->execute() && $success;
}
return $success;
}
public function fetchNMRelations(array $entities, bool $recursive = false) {
if ($recursive) {
foreach ($entities as $entity) {
foreach ($this->relations as $propertyName => $relHandler) {
$relEntity = $this->properties[$propertyName]->getValue($entity);
if ($relEntity) {
$relHandler->fetchNMRelations([$relEntity->getId() => $relEntity], true);
}
}
}
}
if (empty($this->nmRelations)) {
return;
}
$entityIds = array_keys($entities);
foreach ($this->nmRelations as $nmTable => $nmRelation) {
$otherHandler = $nmRelation->getOtherHandler($this);
$thisIdColumn = $nmRelation->getIdColumn($this);
$thisProperties = $nmRelation->getProperties($this);
$thisTableName = $this->getTableName();
$refIdColumn = $nmRelation->getIdColumn($otherHandler);
$refProperties = $nmRelation->getProperties($otherHandler);
$refTableName = $otherHandler->getTableName();
$dataColumns = $nmRelation->getDataColumns();
$relEntityQuery = DatabaseEntityQuery::fetchAll($otherHandler)
->addJoin(new Join("INNER", $nmTable, "$nmTable.$refIdColumn", "$refTableName.id"))
->where(new CondIn(new Column($thisIdColumn), $entityIds))
->getQuery();
$relEntityQuery->addColumn($thisIdColumn);
foreach ($dataColumns as $tableDataColumns) {
foreach ($tableDataColumns as $columnName) {
$relEntityQuery->addColumn($columnName);
}
}
$rows = $relEntityQuery->execute();
if (!is_array($rows)) {
$this->logger->error("Error fetching n:m relations from table: '$nmTable': " . $this->sql->getLastError());
return;
}
$relEntities = [];
foreach ($rows as $row) {
$relId = $row["id"];
if (!isset($relEntities[$relId])) {
$relEntity = $otherHandler->entityFromRow($row);
$relEntities[$relId] = $relEntity;
}
$thisEntity = $entities[$row[$thisIdColumn]];
$relEntity = $relEntities[$relId];
$mappings = [
[$refProperties, $refTableName, $relEntity, $thisEntity],
[$thisProperties, $thisTableName, $thisEntity, $relEntity],
];
foreach ($mappings as $mapping) {
list($properties, $tableName, $targetEntity, $entityToAdd) = $mapping;
foreach ($properties as $propertyName => $property) {
$addToProperty = empty($dataColumns);
if (!$addToProperty) {
$columnName = $dataColumns[$tableName][$propertyName] ?? null;
$addToProperty = ($columnName && $this->sql->parseBool($row[$columnName]));
}
if ($addToProperty) {
$targetArray = $property->getValue($targetEntity);
$targetArray[$entityToAdd->getId()] = $entityToAdd;
$property->setValue($targetEntity, $targetArray);
}
}
}
}
}
}
public function getSelectQuery(): Select {
return $this->sql->select(...$this->getColumnNames())
->from($this->tableName);
@ -259,11 +502,14 @@ class DatabaseEntityHandler {
->first()
->execute();
if ($res === false || $res === null) {
return $res;
} else {
return $this->entityFromRow($res);
if ($res !== false && $res !== null) {
$res = $this->entityFromRow($res);
if ($res instanceof DatabaseEntity) {
$this->fetchNMRelations([$res->getId() => $res]);
}
}
return $res;
}
public function fetchMultiple(?Condition $condition = null): ?array {
@ -284,12 +530,56 @@ class DatabaseEntityHandler {
$entities[$entity->getId()] = $entity;
}
}
$this->fetchNMRelations($entities);
return $entities;
}
}
public function getTableQuery(): CreateTable {
$query = $this->sql->createTable($this->tableName)
public function getCreateQueries(SQL $sql): array {
$queries = [];
$queries[] = $this->getTableQuery($sql);
$table = $this->getTableName();
$entityLogConfig = $this->entityClass->getProperty("entityLogConfig");
$entityLogConfig->setAccessible(true);
$entityLogConfig = $entityLogConfig->getValue();
if (isset($entityLogConfig["insert"]) && $entityLogConfig["insert"] === true) {
$queries[] = $sql->createTrigger("${table}_trg_insert")
->after()->insert($table)
->exec(new CreateProcedure($sql, "InsertEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"),
"lifetime" => $entityLogConfig["lifetime"] ?? 90,
]);
}
if (isset($entityLogConfig["update"]) && $entityLogConfig["update"] === true) {
$queries[] = $sql->createTrigger("${table}_trg_update")
->after()->update($table)
->exec(new CreateProcedure($sql, "UpdateEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"),
]);
}
if (isset($entityLogConfig["delete"]) && $entityLogConfig["delete"] === true) {
$queries[] = $sql->createTrigger("${table}_trg_delete")
->after()->delete($table)
->exec(new CreateProcedure($sql, "DeleteEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"),
]);
}
return $queries;
}
public function getTableQuery(SQL $sql): CreateTable {
$query = $sql->createTable($this->tableName)
->onlyIfNotExists()
->addSerial("id")
->primaryKey("id");
@ -305,12 +595,7 @@ class DatabaseEntityHandler {
return $query;
}
public function createTable(): bool {
$query = $this->getTableQuery();
return $query->execute();
}
private function prepareRow(DatabaseEntity $entity, string $action, ?array $columns = null) {
private function prepareRow(DatabaseEntity $entity, string $action, ?array $columns = null): bool|array {
$row = [];
foreach ($this->columns as $propertyName => $column) {
if ($columns && !in_array($column->getName(), $columns)) {
@ -344,7 +629,7 @@ class DatabaseEntityHandler {
return $row;
}
public function update(DatabaseEntity $entity, ?array $columns = null) {
public function update(DatabaseEntity $entity, ?array $columns = null, bool $saveNM = false) {
$row = $this->prepareRow($entity, "update", $columns);
if ($row === false) {
return false;
@ -358,10 +643,15 @@ class DatabaseEntityHandler {
$query->set($columnName, $value);
}
return $query->execute();
$res = $query->execute();
if ($res && $saveNM) {
$res = $this->updateNM($entity);
}
return $res;
}
public function insert(DatabaseEntity $entity) {
public function insert(DatabaseEntity $entity): bool|int {
$row = $this->prepareRow($entity, "insert");
if ($row === false) {
return false;
@ -391,12 +681,12 @@ class DatabaseEntityHandler {
}
}
public function insertOrUpdate(DatabaseEntity $entity, ?array $columns = null) {
public function insertOrUpdate(DatabaseEntity $entity, ?array $columns = null, bool $saveNM = false) {
$id = $entity->getId();
if ($id === null) {
return $this->insert($entity);
} else {
return $this->update($entity, $columns);
return $this->update($entity, $columns, $saveNM);
}
}
@ -415,4 +705,30 @@ class DatabaseEntityHandler {
public function getSQL(): SQL {
return $this->sql;
}
public function getInsertQuery(DatabaseEntity|array $entities): ?Insert {
if (empty($entities)) {
return null;
}
$firstEntity = (is_array($entities) ? current($entities) : $entities);
$firstRow = $this->prepareRow($firstEntity, "insert");
$statement = $this->sql->insert($this->tableName, array_keys($firstRow))
->addRow(...array_values($firstRow));
if (is_array($entities)) {
foreach ($entities as $entity) {
if ($entity === $firstEntity) {
continue;
}
$row = $this->prepareRow($entity, "insert");
$statement->addRow(...array_values($row));
}
}
return $statement;
}
}

@ -1,6 +1,6 @@
<?php
namespace Core\Objects\DatabaseEntity;
namespace Core\Objects\DatabaseEntity\Controller;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Condition\Condition;
@ -14,18 +14,25 @@ use Core\Driver\SQL\SQL;
*/
class DatabaseEntityQuery {
const FETCH_NONE = 0;
const FETCH_DIRECT = 1;
const FETCH_RECURSIVE = 2;
private Logger $logger;
private DatabaseEntityHandler $handler;
private Select $selectQuery;
private int $resultType;
private bool $logVerbose;
private int $fetchSubEntities;
private function __construct(DatabaseEntityHandler $handler, int $resultType) {
$this->handler = $handler;
$this->selectQuery = $handler->getSelectQuery();
$this->logger = new Logger("DB-EntityQuery", $handler->getSQL());
$this->resultType = $resultType;
$this->logVerbose = false;
$this->fetchSubEntities = self::FETCH_NONE;
if ($this->resultType === SQL::FETCH_ONE) {
$this->selectQuery->first();
@ -50,6 +57,11 @@ class DatabaseEntityQuery {
return $this;
}
public function offset(int $offset): static {
$this->selectQuery->offset($offset);
return $this;
}
public function where(Condition ...$condition): DatabaseEntityQuery {
$this->selectQuery->where(...$condition);
return $this;
@ -74,6 +86,7 @@ class DatabaseEntityQuery {
public function fetchEntities(bool $recursive = false): DatabaseEntityQuery {
// $this->selectQuery->dump();
$this->fetchSubEntities = ($recursive ? self::FETCH_RECURSIVE : self::FETCH_DIRECT);
$relIndex = 1;
foreach ($this->handler->getRelations() as $propertyName => $relationHandler) {
@ -136,9 +149,19 @@ class DatabaseEntityQuery {
$entities[$entity->getId()] = $entity;
}
}
if ($this->fetchSubEntities !== self::FETCH_NONE) {
$this->handler->fetchNMRelations($entities, $this->fetchSubEntities === self::FETCH_RECURSIVE);
}
return $entities;
} else if ($this->resultType === SQL::FETCH_ONE) {
return $this->handler->entityFromRow($res);
$entity = $this->handler->entityFromRow($res);
if ($entity instanceof DatabaseEntity && $this->fetchSubEntities !== self::FETCH_NONE) {
$this->handler->fetchNMRelations([$entity->getId() => $entity], $this->fetchSubEntities === self::FETCH_RECURSIVE);
}
return $entity;
} else {
$this->handler->getLogger()->error("Invalid result type for query builder, must be FETCH_ALL or FETCH_ONE");
return null;
@ -149,4 +172,8 @@ class DatabaseEntityQuery {
$this->selectQuery->addJoin($join);
return $this;
}
public function getQuery(): Select {
return $this->selectQuery;
}
}

@ -0,0 +1,134 @@
<?php
namespace Core\Objects\DatabaseEntity\Controller;
# TODO: Allow more than 2 relations here?
use Core\Driver\SQL\Query\CreateTable;
use Core\Driver\SQL\SQL;
use Core\Driver\SQL\Strategy\CascadeStrategy;
class NMRelation implements Persistable {
private DatabaseEntityHandler $handlerA;
private DatabaseEntityHandler $handlerB;
private array $properties;
public function __construct(DatabaseEntityHandler $handlerA, DatabaseEntityHandler $handlerB) {
$this->handlerA = $handlerA;
$this->handlerB = $handlerB;
$tableNameA = $handlerA->getTableName();
$tableNameB = $handlerB->getTableName();
if ($tableNameA === $tableNameB) {
throw new \Exception("Cannot create N:M Relation with only one table");
}
$this->properties = [
$tableNameA => [],
$tableNameB => [],
];
}
public function addProperty(DatabaseEntityHandler $src, \ReflectionProperty $property): void {
$this->properties[$src->getTableName()][$property->getName()] = $property;
}
public function getIdColumn(DatabaseEntityHandler $handler): string {
return DatabaseEntityHandler::getColumnName($handler->getTableName()) . "_id";
}
public function getDataColumns(): array {
$referenceCount = 0;
$columnsNeeded = false;
// if in one of the relations we have multiple references, we need to differentiate
foreach ($this->properties as $refProperties) {
$referenceCount += count($refProperties);
if ($referenceCount > 1) {
$columnsNeeded = true;
break;
}
}
$columns = [];
if ($columnsNeeded) {
foreach ($this->properties as $tableName => $properties) {
$columns[$tableName] = [];
foreach ($properties as $property) {
$columnName = DatabaseEntityHandler::getColumnName($tableName) . "_" .
DatabaseEntityHandler::getColumnName($property->getName());
$columns[$tableName][$property->getName()] = $columnName;
}
}
}
return $columns;
}
public function getAllColumns(): array {
$relIdA = $this->getIdColumn($this->handlerA);
$relIdB = $this->getIdColumn($this->handlerB);
$columns = [$relIdA, $relIdB];
foreach ($this->getDataColumns() as $dataColumns) {
foreach ($dataColumns as $columnName) {
$columns[] = $columnName;
}
}
return $columns;
}
public function getTableQuery(SQL $sql): CreateTable {
$tableNameA = $this->handlerA->getTableName();
$tableNameB = $this->handlerB->getTableName();
$columns = $this->getAllColumns();
list ($relIdA, $relIdB) = $columns;
$dataColumns = array_slice($columns, 2);
$query = $sql->createTable(self::buildTableName($tableNameA, $tableNameB))
->addInt($relIdA)
->addInt($relIdB)
->foreignKey($relIdA, $tableNameA, "id", new CascadeStrategy())
->foreignKey($relIdB, $tableNameB, "id", new CascadeStrategy());
foreach ($dataColumns as $dataColumn) {
$query->addBool($dataColumn, false);
}
$query->unique(...$columns);
return $query;
}
public static function buildTableName(string ...$tables): string {
sort($tables);
return "NM_" . implode("_", $tables);
}
public function dependsOn(): array {
return [$this->handlerA->getTableName(), $this->handlerB->getTableName()];
}
public function getTableName(): string {
return self::buildTableName(...$this->dependsOn());
}
public function getCreateQueries(SQL $sql): array {
return [$this->getTableQuery($sql)];
}
public function getProperties(DatabaseEntityHandler $handler): array {
return $this->properties[$handler->getTableName()];
}
public function getOtherHandler(DatabaseEntityHandler $handler): DatabaseEntityHandler {
if ($handler === $this->handlerA) {
return $this->handlerB;
} else {
return $this->handlerA;
}
}
}

@ -0,0 +1,13 @@
<?php
namespace Core\Objects\DatabaseEntity\Controller;
use Core\Driver\SQL\SQL;
interface Persistable {
public function dependsOn(): array;
public function getTableName(): string;
public function getCreateQueries(SQL $sql): array;
}

@ -6,6 +6,7 @@ use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class GpgKey extends DatabaseEntity {

@ -3,14 +3,27 @@
namespace Core\Objects\DatabaseEntity;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class Group extends DatabaseEntity {
const ADMIN = 1;
const MODERATOR = 3;
const SUPPORT = 2;
const GROUPS = [
self::ADMIN => "Administrator",
self::MODERATOR => "Moderator",
self::SUPPORT => "Support",
];
#[MaxLength(32)] public string $name;
#[MaxLength(10)] public string $color;
public function __construct(?int $id = null) {
public function __construct(?int $id, string $name, string $color) {
parent::__construct($id);
$this->name = $name;
$this->color = $color;
}
public function jsonSerialize(): array {

@ -2,14 +2,17 @@
namespace Core\Objects\DatabaseEntity {
use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\lang\LanguageModule;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
// TODO: language from cookie?
class Language extends DatabaseEntity {
const AMERICAN_ENGLISH = 1;
const GERMAN_STANDARD = 2;
const LANG_CODE_PATTERN = "/^[a-zA-Z]{2}_[a-zA-Z]{2}$/";
#[MaxLength(5)] private string $code;

@ -0,0 +1,124 @@
<?php
namespace Core\Objects\DatabaseEntity;
use Core\API\Mail\Send;
use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\EnumArr;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class MailQueueItem extends DatabaseEntity {
protected static array $entityLogConfig = [
"update" => true,
"delete" => true,
"insert" => true,
"lifetime" => 30
];
const STATUS_WAITING = "waiting";
const STATUS_SUCCESS = "success";
const STATUS_ERROR = "error";
const STATUS_ITEMS = [self::STATUS_WAITING, self::STATUS_SUCCESS, self::STATUS_ERROR];
#[MaxLength(64)]
private string $from;
#[MaxLength(64)]
private string $to;
private string $subject;
private string $body;
#[MaxLength(64)]
private ?string $replyTo;
#[MaxLength(64)]
private ?string $replyName;
#[MaxLength(64)]
private ?string $gpgFingerprint;
#[EnumArr(self::STATUS_ITEMS)]
#[DefaultValue(self::STATUS_WAITING)]
private string $status;
#[DefaultValue(5)]
private int $retryCount;
#[DefaultValue(CurrentTimeStamp::class)]
private \DateTime $nextTry;
private ?string $errorMessage;
public function __construct(string $fromMail, string $toMail, string $subject, string $body,
?string $replyTo, ?string $replyName, ?string $gpgFingerprint) {
parent::__construct();
$this->from = $fromMail;
$this->to = $toMail;
$this->subject = $subject;
$this->body = $body;
$this->replyTo = $replyTo;
$this->replyName = $replyName;
$this->gpgFingerprint = $gpgFingerprint;
$this->retryCount = 5;
$this->nextTry = new \DateTime();
$this->errorMessage = null;
$this->status = self::STATUS_WAITING;
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"from" => $this->from,
"to" => $this->to,
"gpgFingerprint" => $this->gpgFingerprint,
"subject" => $this->subject,
"message" => $this->body,
"status" => $this->status,
"reply" => [
"to" => $this->replyTo,
"name" => $this->replyName,
],
"retryCount" => $this->retryCount,
"nextTry" => $this->nextTry->getTimestamp(),
"errorMessage" => $this->errorMessage,
];
}
public function send(Context $context): bool {
$args = [
"to" => $this->to,
"subject" => $this->subject,
"body" => $this->body,
"replyTo" => $this->replyTo,
"replyName" => $this->replyName,
"gpgFingerprint" => $this->gpgFingerprint,
"async" => false
];
$req = new Send($context);
$success = $req->execute($args);
$this->errorMessage = $req->getLastError();
$delay = [0, 720, 360, 60, 30, 1];
$minutes = $delay[max(0, min(count($delay) - 1, $this->retryCount))];
if ($this->retryCount > 0) {
$this->retryCount--;
$this->nextTry = (new \DateTime())->modify("+$minutes minute");
} else if (!$success) {
$this->status = self::STATUS_ERROR;
}
if ($success) {
$this->status = self::STATUS_SUCCESS;
}
$this->save($context->getSQL());
return $success;
}
}

@ -6,6 +6,7 @@ use Core\API\Parameter\Parameter;
use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class News extends DatabaseEntity {
@ -16,6 +17,7 @@ class News extends DatabaseEntity {
public function __construct(?int $id = null) {
parent::__construct($id);
$this->publishedAt = new \DateTime();
}
public function jsonSerialize(): array {

@ -1,30 +0,0 @@
<?php
namespace Core\Objects\DatabaseEntity;
use Core\API\Parameter\Parameter;
use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\Enum;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
class Notification extends DatabaseEntity {
#[Enum('default', 'message', 'warning')] private string $type;
#[DefaultValue(CurrentTimeStamp::class)] private \DateTime $createdAt;
#[MaxLength(32)] public string $title;
#[MaxLength(256)] public string $message;
public function __construct(?int $id = null) {
parent::__construct($id);
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"createdAt" => $this->createdAt->format(Parameter::DATE_TIME_FORMAT),
"title" => $this->title,
"message" => $this->message
];
}
}

@ -10,6 +10,7 @@ use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\Json;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class Session extends DatabaseEntity {

@ -7,6 +7,7 @@ use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\Enum;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class SystemLog extends DatabaseEntity {

@ -7,6 +7,7 @@ use Core\Objects\DatabaseEntity\Attribute\Enum;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use Core\Objects\TwoFactor\TimeBasedTwoFactorToken;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
abstract class TwoFactorToken extends DatabaseEntity {

@ -2,14 +2,13 @@
namespace Core\Objects\DatabaseEntity;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Driver\SQL\Join;
use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Attribute\Multiple;
use Core\Objects\DatabaseEntity\Attribute\Unique;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class User extends DatabaseEntity {
@ -20,30 +19,16 @@ class User extends DatabaseEntity {
#[MaxLength(64)] public ?string $profilePicture;
private ?\DateTime $lastOnline;
#[DefaultValue(CurrentTimeStamp::class)] public \DateTime $registeredAt;
public bool $confirmed;
#[DefaultValue(1)] public Language $language;
#[DefaultValue(false)] public bool $confirmed;
#[DefaultValue(Language::AMERICAN_ENGLISH)] public Language $language;
public ?GpgKey $gpgKey;
private ?TwoFactorToken $twoFactorToken;
#[Transient] private array $groups;
#[Multiple(Group::class)]
public array $groups;
public function __construct(?int $id = null) {
parent::__construct($id);
$this->groups = [];
}
public function postFetch(SQL $sql, array $row) {
parent::postFetch($sql, $row);
$this->groups = [];
$groups = Group::findAllBuilder($sql)
->addJoin(new Join("INNER", "UserGroup", "UserGroup.group_id", "Group.id"))
->where(new Compare("UserGroup.user_id", $this->id))
->execute();
if ($groups) {
$this->groups = $groups;
}
}
public function getUsername(): string {

@ -6,6 +6,7 @@ use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\EnumArr;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
class UserToken extends DatabaseEntity {

@ -1,26 +1,5 @@
<?php
const USER_GROUP_MODERATOR = 1;
const USER_GROUP_MODERATOR_NAME = "Moderator";
const USER_GROUP_SUPPORT = 2;
const USER_GROUP_SUPPORT_NAME = "Support";
const USER_GROUP_ADMIN = 3;
const USER_GROUP_ADMIN_NAME = "Administrator";
const DEFAULT_GROUPS = array(
USER_GROUP_MODERATOR, USER_GROUP_SUPPORT, USER_GROUP_ADMIN
);
function GroupName($index) {
$groupNames = array(
USER_GROUP_MODERATOR => USER_GROUP_MODERATOR_NAME,
USER_GROUP_SUPPORT => USER_GROUP_SUPPORT_NAME,
USER_GROUP_ADMIN => USER_GROUP_ADMIN_NAME,
);
return ($groupNames[$index] ?? "Unknown Group");
}
// adapted from https://www.php.net/manual/en/function.http-response-code.php
const HTTP_STATUS_DESCRIPTIONS = [
100 => 'Continue',

@ -10,12 +10,12 @@ if (is_file($autoLoad)) {
require_once $autoLoad;
}
define("WEBBASE_VERSION", "2.2.0");
define("WEBBASE_VERSION", "2.3.0");
spl_autoload_extensions(".php");
spl_autoload_register(function ($class) {
if (!class_exists($class)) {
$suffixes = ["", ".class", ".trait"];
$suffixes = ["", ".class", ".trait", ".interface"];
foreach ($suffixes as $suffix) {
$full_path = WEBROOT . "/" . getClassPath($class, $suffix);
if (file_exists($full_path)) {

@ -94,13 +94,8 @@ function handleDatabase(array $argv) {
$action = $argv[2] ?? "";
if ($action === "migrate") {
$class = $argv[3] ?? null;
if (!$class) {
_exit("Usage: cli.php db migrate <class name>");
}
$sql = connectSQL() or die();
applyPatch($sql, $class);
} else if (in_array($action, ["export", "import", "shell"])) {
// database config

@ -4,8 +4,8 @@ use Core\API\Parameter\Parameter;
use Core\Driver\SQL\Query\CreateTable;
use Core\Driver\SQL\SQL;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\DatabaseEntity;
use Core\Objects\DatabaseEntity\DatabaseEntityHandler;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler;
use Core\Objects\DatabaseEntity\User;
class DatabaseEntityTest extends \PHPUnit\Framework\TestCase {
@ -28,8 +28,9 @@ class DatabaseEntityTest extends \PHPUnit\Framework\TestCase {
}
public function testCreateTable() {
$this->assertInstanceOf(CreateTable::class, self::$HANDLER->getTableQuery());
$this->assertTrue(self::$HANDLER->createTable());
$query = self::$HANDLER->getTableQuery(self::$CONTEXT->getSQL());
$this->assertInstanceOf(CreateTable::class, $query);
$this->assertTrue($query->execute());
}
public function testInsertEntity() {

@ -56,6 +56,7 @@ class ParameterTest extends \PHPUnit\Framework\TestCase {
// optional single value
$arrayType = new ArrayType("int_array_single", Parameter::TYPE_INT, true);
$this->assertTrue($arrayType->parseParam(1));
$this->assertEquals([1], $arrayType->value);
// mixed values
$arrayType = new ArrayType("mixed_array", Parameter::TYPE_MIXED);

@ -27,17 +27,4 @@ class TimeBasedTwoFactorTokenTest extends PHPUnit\Framework\TestCase {
$this->assertEquals($code, $generated, "$code != $generated, at=$seed");
}
}
public function testURL() {
$secret = Base32::encode("12345678901234567890");
$context = new Context();
// $context->
$token = new TimeBasedTwoFactorToken($secret);
$siteName = $context->getSettings()->getSiteName();
$username = $context->getUser()->getUsername();
$url = $token->getUrl($context);
$this->assertEquals("otpauth://totp/$username?secret=$secret&issuer=$siteName", $url);
}
}