Namespace and ClassPath rewrites

This commit is contained in:
2022-11-18 18:06:46 +01:00
parent c277aababc
commit 951ff14c5f
217 changed files with 1017 additions and 936 deletions

View File

@@ -0,0 +1,166 @@
<?php
namespace Core\API {
use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context;
abstract class ApiKeyAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
}
protected function apiKeyExists(int $id): bool {
$sql = $this->context->getSQL();
$res = $sql->select($sql->count())
->from("ApiKey")
->where(new Compare("id", $id))
->where(new Compare("user_id", $this->context->getUser()->getId()))
->where(new Compare("valid_until", $sql->currentTimestamp(), ">"))
->where(new Compare("active", 1))
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if($this->success && $res[0]["count"] === 0) {
return $this->createError("This API-Key does not exist.");
}
return $this->success;
}
}
}
namespace Core\API\ApiKey {
use Core\API\ApiKeyAPI;
use Core\API\Parameter\Parameter;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondAnd;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\ApiKey;
class Create extends ApiKeyAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array());
$this->apiKeyAllowed = false;
$this->loginRequired = true;
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$apiKey = new ApiKey();
$apiKey->apiKey = generateRandomString(64);
$apiKey->validUntil = (new \DateTime())->modify("+30 DAY");
$apiKey->user = $this->context->getUser();
$this->success = $apiKey->save($sql);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["api_key"] = $apiKey->jsonSerialize();
}
return $this->success;
}
}
class Fetch extends ApiKeyAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
"showActiveOnly" => new Parameter("showActiveOnly", Parameter::TYPE_BOOLEAN, true, true)
));
$this->loginRequired = true;
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$condition = new Compare("user_id", $this->context->getUser()->getId());
if ($this->getParam("showActiveOnly")) {
$condition = new CondAnd(
$condition,
new Compare("valid_until", $sql->currentTimestamp(), ">"),
new Compare("active", true)
);
}
$apiKeys = ApiKey::findAll($sql, $condition);
$this->success = ($apiKeys !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["api_keys"] = array();
foreach($apiKeys as $apiKey) {
$this->result["api_keys"][$apiKey->getId()] = $apiKey->jsonSerialize();
}
}
return $this->success;
}
}
class Refresh extends ApiKeyAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT),
));
$this->loginRequired = true;
}
public function _execute(): bool {
$id = $this->getParam("id");
if (!$this->apiKeyExists($id)) {
return false;
}
$validUntil = (new \DateTime)->modify("+30 DAY");
$sql = $this->context->getSQL();
$this->success = $sql->update("ApiKey")
->set("valid_until", $validUntil)
->where(new Compare("id", $id))
->where(new Compare("user_id", $this->context->getUser()->getId()))
->execute();
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["valid_until"] = $validUntil;
}
return $this->success;
}
}
class Revoke extends ApiKeyAPI {
public function __construct($user, $externalCall = false) {
parent::__construct($user, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT),
));
$this->loginRequired = true;
}
public function _execute(): bool {
$id = $this->getParam("id");
if (!$this->apiKeyExists($id)) {
return false;
}
$sql = $this->context->getSQL();
$this->success = $sql->update("ApiKey")
->set("active", false)
->where(new Compare("id", $id))
->where(new Compare("user_id", $this->context->getUser()->getId()))
->execute();
$this->lastError = $sql->getLastError();
return $this->success;
}
}
}

View File

@@ -0,0 +1,291 @@
<?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;
}
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace Core\API {
use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context;
abstract class GroupsAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
}
protected function groupExists($name): bool {
$sql = $this->context->getSQL();
$res = $sql->select($sql->count())
->from("Group")
->where(new Compare("name", $name))
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success && $res[0]["count"] > 0;
}
}
}
namespace Core\API\Groups {
use Core\API\GroupsAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
class Fetch extends GroupsAPI {
private int $groupCount;
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'page' => new Parameter('page', Parameter::TYPE_INT, true, 1),
'count' => new Parameter('count', Parameter::TYPE_INT, true, 20)
));
$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) {
return $this->createError("Invalid page count");
}
$count = $this->getParam("count");
if($count < 1 || $count > 50) {
return $this->createError("Invalid fetch count");
}
if (!$this->fetchGroupCount()) {
return false;
}
$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")
->ascending()
->limit($count)
->offset(($page - 1) * $count)
->execute();
$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,
);
}
$this->result["pageCount"] = intval(ceil($this->groupCount / $count));
$this->result["totalCount"] = $this->groupCount;
}
return $this->success;
}
}
class Create extends GroupsAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'name' => new StringType('name', 32),
'color' => new StringType('color', 10),
));
}
public function _execute(): bool {
$name = $this->getParam("name");
if (preg_match("/^[a-zA-Z][a-zA-Z0-9_-]*$/", $name) !== 1) {
return $this->createError("Invalid name");
}
$color = $this->getParam("color");
if (preg_match("/^#[a-fA-F0-9]{3,6}$/", $color) !== 1) {
return $this->createError("Invalid color");
}
$exists = $this->groupExists($name);
if (!$this->success) {
return false;
} else if ($exists) {
return $this->createError("A group with this name already exists");
}
$sql = $this->context->getSQL();
$group = new Group();
$group->name = $name;
$group->color = $color;
$this->success = ($group->save($sql) !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["id"] = $group->getId();
}
return $this->success;
}
}
class Delete extends GroupsAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'id' => new Parameter('id', Parameter::TYPE_INT)
));
}
public function _execute(): bool {
$id = $this->getParam("id");
if (in_array($id, DEFAULT_GROUPS)) {
return $this->createError("You cannot delete a default group.");
}
$sql = $this->context->getSQL();
$group = Group::find($sql, $id);
$this->success = ($group !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success && $group === null) {
return $this->createError("This group does not exist.");
}
$this->success = ($group->delete($sql) !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success;
}
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class LanguageAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
}
}
}
namespace Core\API\Language {
use Core\API\LanguageAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondOr;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Language;
class Get extends LanguageAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array());
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$languages = Language::findAll($sql);
$this->success = ($languages !== null);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result['languages'] = [];
if (count($languages) === 0) {
$this->lastError = L("No languages found");
} else {
foreach ($languages as $language) {
$this->result['languages'][$language->getId()] = $language->jsonSerialize();
}
}
}
return $this->success;
}
}
class Set extends LanguageAPI {
private Language $language;
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'langId' => new Parameter('langId', Parameter::TYPE_INT, true, NULL),
'langCode' => new StringType('langCode', 5, true, NULL),
));
}
private function checkLanguage(): bool {
$langId = $this->getParam("langId");
$langCode = $this->getParam("langCode");
if (is_null($langId) && is_null($langCode)) {
return $this->createError(L("Either langId or langCode must be given"));
}
$sql = $this->context->getSQL();
$languages = Language::findAll($sql,
new CondOr(new Compare("id", $langId), new Compare("code", $langCode))
);
$this->success = ($languages !== null);
$this->lastError = $sql->getLastError();
if ($this->success) {
if (count($languages) === 0) {
return $this->createError(L("This Language does not exist"));
} else {
$this->language = array_shift($languages);
}
}
return $this->success;
}
private function updateLanguage(): bool {
$languageId = $this->language->getId();
$userId = $this->context->getUser()->getId();
$sql = $this->context->getSQL();
$this->success = $sql->update("User")
->set("language_id", $languageId)
->where(new Compare("id", $userId))
->execute();
$this->lastError = $sql->getLastError();
return $this->success;
}
public function _execute(): bool {
if (!$this->checkLanguage())
return false;
if ($this->context->getSession()) {
$this->updateLanguage();
}
$this->context->setLanguage($this->language);
return $this->success;
}
}
}

122
Core/API/LogsAPI.class.php Normal file
View File

@@ -0,0 +1,122 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class LogsAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
}
}
}
namespace Core\API\Logs {
use Core\API\LogsAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondIn;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\SystemLog;
class Get extends LogsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"since" => new Parameter("since", Parameter::TYPE_DATE_TIME, true),
"severity" => new StringType("severity", 32, true, "debug")
]);
$this->csrfTokenRequired = false;
}
protected function _execute(): bool {
$since = $this->getParam("since");
$sql = $this->context->getSQL();
$severity = strtolower(trim($this->getParam("severity")));
$shownLogLevels = Logger::LOG_LEVELS;
$logLevel = array_search($severity, Logger::LOG_LEVELS, true);
if ($logLevel === false) {
return $this->createError("Invalid severity. Allowed values: " . implode(",", Logger::LOG_LEVELS));
} else if ($logLevel > 0) {
$shownLogLevels = array_slice(Logger::LOG_LEVELS, $logLevel);
}
$query = SystemLog::findAllBuilder($sql)
->orderBy("timestamp")
->descending();
if ($since !== null) {
$query->where(new Compare("timestamp", $since, ">="));
}
if ($logLevel > 0) {
$query->where(new CondIn(new Column("severity"), $shownLogLevels));
}
$logEntries = $query->execute();
$this->success = $logEntries !== false;
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["logs"] = [];
foreach ($logEntries as $logEntry) {
$this->result["logs"][] = $logEntry->jsonSerialize();
}
} else {
// we couldn't fetch logs from database, return a message and proceed to log files
$this->result["logs"] = [
[
"id" => "fetch-fail",
"module" => "LogsAPI",
"message" => "Failed retrieving logs from database: " . $this->lastError,
"severity" => "error",
"timestamp" => (new \DateTime())->format(Parameter::DATE_TIME_FORMAT)
]
];
}
// get all log entries from filesystem (if database failed)
$logPath = realpath(implode(DIRECTORY_SEPARATOR, [WEBROOT, "Core", "Logs"]));
if ($logPath) {
$index = 1;
foreach (scandir($logPath) as $fileName) {
$logFile = $logPath . DIRECTORY_SEPARATOR . $fileName;
// {module}_{severity}_{date}_{time}_{ms}.log
if (preg_match("/^(\w+)_(\w+)_((\d+-\d+-\d+_){2}\d+)\.log$/", $fileName, $matches) && is_file($logFile)) {
$content = @file_get_contents($logFile);
$date = \DateTime::createFromFormat(Logger::LOG_FILE_DATE_FORMAT, $matches[3]);
if ($content && $date) {
// filter log date
if ($since !== null && datetimeDiff($date, $since) < 0) {
continue;
}
// filter log level
if (!in_array(trim(strtolower($matches[2])), $shownLogLevels)) {
continue;
}
$this->result["logs"][] = [
"id" => "file-" . ($index++),
"module" => $matches[1],
"severity" => $matches[2],
"message" => $content,
"timestamp" => $date->format(Parameter::DATE_TIME_FORMAT)
];
}
}
}
}
return true;
}
}
}

295
Core/API/MailAPI.class.php Normal file
View File

@@ -0,0 +1,295 @@
<?php
namespace Core\API {
use Core\Objects\ConnectionData;
use Core\Objects\Context;
abstract class MailAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
}
protected function getMailConfig(): ?ConnectionData {
$req = new \Core\API\Settings\Get($this->context);
$this->success = $req->execute(array("key" => "^mail_"));
$this->lastError = $req->getLastError();
if ($this->success) {
$settings = $req->getResult()["settings"];
if (!isset($settings["mail_enabled"]) || $settings["mail_enabled"] !== "1") {
$this->createError("Mail is not configured yet.");
return null;
}
$host = $settings["mail_host"] ?? "localhost";
$port = intval($settings["mail_port"] ?? "25");
$login = $settings["mail_username"] ?? "";
$password = $settings["mail_password"] ?? "";
$connectionData = new ConnectionData($host, $port, $login, $password);
$connectionData->setProperty("from", $settings["mail_from"] ?? "");
$connectionData->setProperty("last_sync", $settings["mail_last_sync"] ?? "");
$connectionData->setProperty("mail_footer", $settings["mail_footer"] ?? "");
return $connectionData;
}
return null;
}
}
}
namespace Core\API\Mail {
use Core\API\MailAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use DateTimeInterface;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondIn;
use Core\External\PHPMailer\Exception;
use Core\External\PHPMailer\PHPMailer;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\GpgKey;
class Test extends MailAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"receiver" => new Parameter("receiver", Parameter::TYPE_EMAIL),
"gpgFingerprint" => new StringType("gpgFingerprint", 64, true, null)
));
}
public function _execute(): bool {
$receiver = $this->getParam("receiver");
$req = new \Core\API\Mail\Send($this->context);
$this->success = $req->execute(array(
"to" => $receiver,
"subject" => "Test E-Mail",
"body" => "Hey! If you receive this e-mail, your mail configuration seems to be working.",
"gpgFingerprint" => $this->getParam("gpgFingerprint"),
"async" => false
));
$this->lastError = $req->getLastError();
return $this->success;
}
}
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),
'subject' => new StringType('subject', -1),
'body' => new StringType('body', -1),
'replyTo' => new Parameter('replyTo', Parameter::TYPE_EMAIL, true, null),
'replyName' => new StringType('replyName', 32, true, ""),
'gpgFingerprint' => new StringType("gpgFingerprint", 64, true, null),
'async' => new Parameter("async", Parameter::TYPE_BOOLEAN, true, true)
));
$this->isPublic = false;
}
public function _execute(): bool {
$mailConfig = $this->getMailConfig();
if (!$this->success || $mailConfig === null) {
return false;
}
$fromMail = $mailConfig->getProperty('from');
$mailFooter = $mailConfig->getProperty('mail_footer');
$toMail = $this->getParam('to') ?? $fromMail;
$subject = $this->getParam('subject');
$replyTo = $this->getParam('replyTo');
$replyName = $this->getParam('replyName');
$body = $this->getParam('body');
$gpgFingerprint = $this->getParam("gpgFingerprint");
if ($this->getParam("async")) {
$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;
$this->lastError = $sql->getLastError();
return $this->success;
}
if (stripos($body, "<body") === false) {
$body = "<body>$body</body>";
}
if (stripos($body, "<html") === false) {
$body = "<html>$body</html>";
}
if (!empty($mailFooter)) {
$email_signature = realpath(WEBROOT . DIRECTORY_SEPARATOR . $mailFooter);
if (is_file($email_signature)) {
$email_signature = file_get_contents($email_signature);
$body .= $email_signature;
}
}
try {
$mail = new PHPMailer;
$mail->IsSMTP();
$mail->setFrom($fromMail);
$mail->addAddress($toMail);
if ($replyTo) {
$mail->addReplyTo($replyTo, $replyName);
}
$mail->Subject = $subject;
$mail->SMTPDebug = 0;
$mail->Host = $mailConfig->getHost();
$mail->Port = $mailConfig->getPort();
$mail->SMTPAuth = true;
$mail->Timeout = 15;
$mail->Username = $mailConfig->getLogin();
$mail->Password = $mailConfig->getPassword();
$mail->SMTPSecure = 'tls';
$mail->CharSet = 'UTF-8';
if ($gpgFingerprint) {
$encryptedHeaders = implode("\r\n", [
"Date: " . (new \DateTime())->format(DateTimeInterface::RFC2822),
"Content-Type: text/html",
"Content-Transfer-Encoding: quoted-printable"
]);
$mimeBody = $encryptedHeaders . "\r\n\r\n" . quoted_printable_encode($body);
$res = GpgKey::encrypt($mimeBody, $gpgFingerprint);
if ($res["success"]) {
$encryptedBody = $res["data"];
$mail->AltBody = '';
$mail->Body = '';
$mail->AllowEmpty = true;
$mail->ContentType = PHPMailer::CONTENT_TYPE_MULTIPART_ENCRYPTED;
$mail->addStringAttachment("Version: 1", null, PHPMailer::ENCODING_BASE64, "application/pgp-encrypted", "");
$mail->addStringAttachment($encryptedBody, "encrypted.asc", PHPMailer::ENCODING_7BIT, "application/octet-stream", "");
} else {
return $this->createError($res["error"]);
}
} else {
$mail->msgHTML($body);
$mail->AltBody = strip_tags($body);
}
$this->success = @$mail->Send();
if (!$this->success) {
$this->lastError = "Error sending Mail: $mail->ErrorInfo";
$this->logger->error("sendMail() failed: $mail->ErrorInfo");
} else {
$this->result["messageId"] = $mail->getLastMessageID();
}
} catch (Exception $e) {
$this->success = false;
$this->lastError = "Error sending Mail: $e";
$this->logger->error($this->lastError);
}
return $this->success;
}
}
class SendQueue extends MailAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"debug" => new Parameter("debug", Parameter::TYPE_BOOLEAN, true, false)
]);
$this->isPublic = false;
}
public function _execute(): bool {
$debug = $this->getParam("debug");
$startTime = time();
if ($debug) {
echo "Start of processing mail queue at $startTime" . PHP_EOL;
}
$sql = $this->context->getSQL();
$res = $sql->select("id", "from", "to", "subject", "body",
"replyTo", "replyName", "gpgFingerprint", "retryCount")
->from("MailQueue")
->where(new Compare("retryCount", 0, ">"))
->where(new Compare("status", "waiting"))
->where(new Compare("nextTry", $sql->now(), "<="))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success && is_array($res)) {
if ($debug) {
echo "Found " . count($res) . " mails to send" . PHP_EOL;
}
$successfulMails = [];
foreach ($res as $row) {
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;
}
$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;
}
}
$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();
}
}
return $this->success;
}
}
}

158
Core/API/NewsAPI.class.php Normal file
View File

@@ -0,0 +1,158 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class NewsAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
$this->loginRequired = true;
}
}
}
namespace Core\API\News {
use Core\API\NewsAPI;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\News;
class Get extends NewsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"since" => new Parameter("since", Parameter::TYPE_DATE_TIME, true, null),
"limit" => new Parameter("limit", Parameter::TYPE_INT, true, 10)
]);
$this->loginRequired = false;
}
public function _execute(): bool {
$since = $this->getParam("since");
$limit = $this->getParam("limit");
if ($limit < 1 || $limit > 30) {
return $this->createError("Limit must be in range 1-30");
}
$sql = $this->context->getSQL();
$newsQuery = News::findAllBuilder($sql)
->limit($limit)
->orderBy("published_at")
->descending()
->fetchEntities();
if ($since) {
$newsQuery->where(new Compare("published_at", $since, ">="));
}
$newsArray = $newsQuery->execute();
$this->success = $newsArray !== null;
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["news"] = [];
foreach ($newsArray as $news) {
$newsId = $news->getId();
$this->result["news"][$newsId] = $news->jsonSerialize();
}
}
return $this->success;
}
}
class Publish extends NewsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"title" => new StringType("title", 128),
"text" => new StringType("text", 1024)
]);
$this->loginRequired = true;
}
public function _execute(): bool {
$news = new News();
$news->text = $this->getParam("text");
$news->title = $this->getParam("title");
$news->publishedBy = $this->context->getUser();
$sql = $this->context->getSQL();
$this->success = $news->save($sql);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["newsId"] = $news->getId();
}
return $this->success;
}
}
class Delete extends NewsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"id" => new Parameter("id", Parameter::TYPE_INT)
]);
$this->loginRequired = true;
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$currentUser = $this->context->getUser();
$news = News::find($sql, $this->getParam("id"));
$this->success = ($news !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
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)) {
return $this->createError("You do not have permissions to delete news post of other users.");
}
$this->success = $news->delete($sql);
$this->lastError = $sql->getLastError();
return $this->success;
}
}
class Edit extends NewsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"id" => new Parameter("id", Parameter::TYPE_INT),
"title" => new StringType("title", 128),
"text" => new StringType("text", 1024)
]);
$this->loginRequired = true;
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$currentUser = $this->context->getUser();
$news = News::find($sql, $this->getParam("id"));
$this->success = ($news !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
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)) {
return $this->createError("You do not have permissions to edit news post of other users.");
}
$news->text = $this->getParam("text");
$news->title = $this->getParam("title");
$this->success = $news->save($sql);
$this->lastError = $sql->getLastError();
return $this->success;
}
}
}

View File

@@ -0,0 +1,231 @@
<?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;
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace Core\API\Parameter;
class ArrayType extends Parameter {
private Parameter $elementParameter;
public int $elementType;
public int $canBeOne;
/**
* ArrayType constructor.
* @param string $name the name of the parameter
* @param int $elementType element type inside the array, for example, allow only integer values (Parameter::TYPE_INT)
* @param bool $canBeOne true, if a single element can be passed inside the request (e.g. array=1 instead of array[]=1). Will be automatically casted to an array
* @param bool $optional true if the parameter is optional
* @param array|null $defaultValue the default value to use, if the parameter is not given
*/
public function __construct(string $name, int $elementType = Parameter::TYPE_MIXED, bool $canBeOne = false, bool $optional = FALSE, ?array $defaultValue = NULL) {
$this->elementType = $elementType;
$this->elementParameter = new Parameter('', $elementType);
$this->canBeOne = $canBeOne;
parent::__construct($name, Parameter::TYPE_ARRAY, $optional, $defaultValue);
}
public function parseParam($value): bool {
if(!is_array($value)) {
if (!$this->canBeOne) {
return false;
} else {
$value = array($value);
}
}
if ($this->elementType != Parameter::TYPE_MIXED) {
foreach ($value as &$element) {
if ($this->elementParameter->parseParam($element)) {
$element = $this->elementParameter->value;
} else {
return false;
}
}
}
$this->value = $value;
return true;
}
public function getTypeName(): string {
$elementType = $this->elementParameter->getTypeName();
return parent::getTypeName() . "($elementType)";
}
public function toString(): string {
$typeName = $this->getTypeName();
$str = "$typeName $this->name";
$defaultValue = (is_null($this->value) ? 'NULL' : (is_array($this->value) ? '[' . implode(",", $this->value) . ']' : $this->value));
if($this->optional) {
$str = "[$str = $defaultValue]";
}
return $str;
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace Core\API\Parameter;
use DateTime;
class Parameter {
const TYPE_INT = 0;
const TYPE_FLOAT = 1;
const TYPE_BOOLEAN = 2;
const TYPE_STRING = 3;
const TYPE_DATE = 4;
const TYPE_TIME = 5;
const TYPE_DATE_TIME = 6;
const TYPE_EMAIL = 7;
// only internal access
const TYPE_RAW = 8;
// only json will work here I guess
// nope. also name[]=value
const TYPE_ARRAY = 9;
const TYPE_MIXED = 10;
const names = array('Integer', 'Float', 'Boolean', 'String', 'Date', 'Time', 'DateTime', 'E-Mail', 'Raw', 'Array', 'Mixed');
const DATE_FORMAT = "Y-m-d";
const TIME_FORMAT = "H:i:s";
const DATE_TIME_FORMAT = self::DATE_FORMAT . " " . self::TIME_FORMAT;
private $defaultValue;
public string $name;
public $value;
public bool $optional;
public int $type;
public string $typeName;
public function __construct(string $name, int $type, bool $optional = FALSE, $defaultValue = NULL) {
$this->name = $name;
$this->optional = $optional;
$this->defaultValue = $defaultValue;
$this->value = $defaultValue;
$this->type = $type;
$this->typeName = $this->getTypeName();
}
public function reset() {
$this->value = $this->defaultValue;
}
public function getSwaggerTypeName(): string {
$typeName = strtolower(($this->type >= 0 && $this->type < count(Parameter::names)) ? Parameter::names[$this->type] : "invalid");
if ($typeName === "mixed" || $typeName === "raw") {
return "object";
}
if (!in_array($typeName, ["array", "boolean", "integer", "number", "object", "string"])) {
return "string";
}
return $typeName;
}
public function getSwaggerFormat(): ?string {
switch ($this->type) {
case self::TYPE_DATE:
return self::DATE_FORMAT;
case self::TYPE_TIME:
return self::TIME_FORMAT;
case self::TYPE_DATE_TIME:
return self::DATE_TIME_FORMAT;
case self::TYPE_EMAIL:
return "email";
default:
return null;
}
}
public function getTypeName(): string {
return ($this->type >= 0 && $this->type < count(Parameter::names)) ? Parameter::names[$this->type] : "INVALID";
}
public function toString(): string {
$typeName = Parameter::names[$this->type];
$str = "$typeName $this->name";
$defaultValue = (is_null($this->value) ? 'NULL' : $this->value);
if($this->optional) {
$str = "[$str = $defaultValue]";
}
return $str;
}
public static function parseType($value): int {
if(is_array($value))
return Parameter::TYPE_ARRAY;
else if(is_numeric($value) && intval($value) == $value)
return Parameter::TYPE_INT;
else if(is_float($value) || (is_numeric($value) && floatval($value) == $value))
return Parameter::TYPE_FLOAT;
else if(is_bool($value) || $value == "true" || $value == "false")
return Parameter::TYPE_BOOLEAN;
else if(is_a($value, 'DateTime'))
return Parameter::TYPE_DATE_TIME;
else if($value !== null && ($d = DateTime::createFromFormat(self::DATE_FORMAT, $value)) && $d->format(self::DATE_FORMAT) === $value)
return Parameter::TYPE_DATE;
else if($value !== null && ($d = DateTime::createFromFormat(self::TIME_FORMAT, $value)) && $d->format(self::TIME_FORMAT) === $value)
return Parameter::TYPE_TIME;
else if($value !== null && ($d = DateTime::createFromFormat(self::DATE_TIME_FORMAT, $value)) && $d->format(self::DATE_TIME_FORMAT) === $value)
return Parameter::TYPE_DATE_TIME;
else if (filter_var($value, FILTER_VALIDATE_EMAIL))
return Parameter::TYPE_EMAIL;
else
return Parameter::TYPE_STRING;
}
public function parseParam($value): bool {
switch($this->type) {
case Parameter::TYPE_INT:
if(is_numeric($value) && intval($value) == $value) {
$this->value = intval($value);
return true;
}
return false;
case Parameter::TYPE_FLOAT:
if(is_numeric($value) && (floatval($value) == $value || intval($value) == $value)) {
$this->value = floatval($value);
return true;
}
return false;
case Parameter::TYPE_BOOLEAN:
if(strcasecmp($value, 'true') === 0)
$this->value = true;
else if(strcasecmp($value, 'false') === 0)
$this->value = false;
else if(is_bool($value))
$this->value = (bool)$value;
else
return false;
return true;
case Parameter::TYPE_DATE:
if(is_a($value, "DateTime")) {
$this->value = $value;
return true;
}
$d = DateTime::createFromFormat(self::DATE_FORMAT, $value);
if($d && $d->format(self::DATE_FORMAT) === $value) {
$this->value = $d;
return true;
}
return false;
case Parameter::TYPE_TIME:
if(is_a($value, "DateTime")) {
$this->value = $value;
return true;
}
$d = DateTime::createFromFormat(self::TIME_FORMAT, $value);
if($d && $d->format(self::TIME_FORMAT) === $value) {
$this->value = $d;
return true;
}
return false;
case Parameter::TYPE_DATE_TIME:
if(is_a($value, 'DateTime')) {
$this->value = $value;
return true;
} else {
$d = DateTime::createFromFormat(self::DATE_TIME_FORMAT, $value);
if($d && $d->format(self::DATE_TIME_FORMAT) === $value) {
$this->value = $d;
return true;
}
}
return false;
case Parameter::TYPE_EMAIL:
if (filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->value = $value;
return true;
}
return false;
case Parameter::TYPE_ARRAY:
if(is_array($value)) {
$this->value = $value;
return true;
}
return false;
default:
$this->value = $value;
return true;
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Core\API\Parameter;
class StringType extends Parameter {
public int $maxLength;
public function __construct(string $name, int $maxLength = -1, bool $optional = FALSE, ?string $defaultValue = NULL) {
$this->maxLength = $maxLength;
parent::__construct($name, Parameter::TYPE_STRING, $optional, $defaultValue);
}
public function parseParam($value): bool {
if(!is_string($value)) {
return false;
}
if($this->maxLength > 0 && strlen($value) > $this->maxLength) {
return false;
}
$this->value = $value;
return true;
}
public function getTypeName(): string {
$maxLength = ($this->maxLength > 0 ? "($this->maxLength)" : "");
return parent::getTypeName() . $maxLength;
}
public function toString(): string {
$typeName = $this->getTypeName();
$str = "$typeName $this->name";
$defaultValue = (is_null($this->value) ? 'NULL' : $this->value);
if($this->optional) {
$str = "[$str = $defaultValue]";
}
return $str;
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class PermissionAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
}
protected function checkStaticPermission(): bool {
$user = $this->context->getUser();
if (!$user || !$user->hasGroup(USER_GROUP_ADMIN)) {
return $this->createError("Permission denied.");
}
return true;
}
}
}
namespace Core\API\Permission {
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\PermissionAPI;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Condition\CondLike;
use Core\Driver\SQL\Condition\CondNot;
use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\User;
class Check extends PermissionAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
'method' => new StringType('method', 323)
));
$this->isPublic = false;
}
public function _execute(): bool {
$method = $this->getParam("method");
$sql = $this->context->getSQL();
$res = $sql->select("groups")
->from("ApiPermission")
->where(new CondLike($method, new Column("method")))
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
if (empty($res) || !is_array($res)) {
return true;
}
$groups = json_decode($res[0]["groups"]);
if (empty($groups)) {
return true;
}
$currentUser = $this->context->getUser();
$userGroups = $currentUser ? $currentUser->getGroups() : [];
if (empty($userGroups) || empty(array_intersect($groups, array_keys($userGroups)))) {
http_response_code(401);
return $this->createError("Permission denied.");
}
}
return $this->success;
}
}
class Fetch extends PermissionAPI {
private ?array $groups;
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array());
}
private function fetchGroups(): bool {
$sql = $this->context->getSQL();
$this->groups = Group::findAll($sql);
$this->success = ($this->groups !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success;
}
public function _execute(): bool {
if (!$this->fetchGroups()) {
return false;
}
$sql = $this->context->getSQL();
$res = $sql->select("method", "groups", "description")
->from("ApiPermission")
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
$permissions = array();
foreach ($res as $row) {
$method = $row["method"];
$description = $row["description"];
$groups = json_decode($row["groups"]);
$permissions[] = array(
"method" => $method,
"groups" => $groups,
"description" => $description
);
}
$this->result["permissions"] = $permissions;
$this->result["groups"] = $this->groups;
}
return $this->success;
}
}
class Save extends PermissionAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
'permissions' => new Parameter('permissions', Parameter::TYPE_ARRAY)
));
}
public function _execute(): bool {
if (!$this->checkStaticPermission()) {
return false;
}
$permissions = $this->getParam("permissions");
$sql = $this->context->getSQL();
$methodParam = new StringType('method', 32);
$groupsParam = new Parameter('groups', Parameter::TYPE_ARRAY);
$updateQuery = $sql->insert("ApiPermission", array("method", "groups"))
->onDuplicateKeyStrategy(new UpdateStrategy(array("method"), array("groups" => new Column("groups"))));
$insertedMethods = array();
foreach ($permissions as $permission) {
if (!is_array($permission)) {
return $this->createError("Invalid data type found in parameter: permissions, expected: object");
} else if (!isset($permission["method"]) || !array_key_exists("groups", $permission)) {
return $this->createError("Invalid object found in parameter: permissions, expected keys 'method' and 'groups'");
} else if (!$methodParam->parseParam($permission["method"])) {
$expectedType = $methodParam->getTypeName();
return $this->createError("Invalid data type found for attribute 'method', expected: $expectedType");
} else if (!$groupsParam->parseParam($permission["groups"])) {
$expectedType = $groupsParam->getTypeName();
return $this->createError("Invalid data type found for attribute 'groups', expected: $expectedType");
} else if (empty(trim($methodParam->value))) {
return $this->createError("Method cannot be empty.");
} else {
$method = $methodParam->value;
$groups = $groupsParam->value;
$updateQuery->addRow($method, $groups);
$insertedMethods[] = $method;
}
}
if (!empty($permissions)) {
$res = $updateQuery->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
}
if ($this->success) {
$res = $sql->delete("ApiPermission")
->where(new Compare("description", "")) // only delete non default permissions
->where(new CondNot(new CondIn(new Column("method"), $insertedMethods)))
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
}
return $this->success;
}
}
}

435
Core/API/Request.class.php Normal file
View File

@@ -0,0 +1,435 @@
<?php
namespace Core\API;
use Core\Driver\Logger\Logger;
use Core\Objects\Context;
use PhpMqtt\Client\MqttClient;
/**
* TODO: we need following features, probably as abstract/generic class/method:
* - easy way for pagination (select with limit/offset)
* - CRUD Endpoints/Objects (Create, Update, Delete)
*/
abstract class Request {
protected Context $context;
protected Logger $logger;
protected array $params;
protected string $lastError;
protected array $result;
protected bool $success;
protected bool $isPublic;
protected bool $loginRequired;
protected bool $variableParamCount;
protected bool $isDisabled;
protected bool $apiKeyAllowed;
protected bool $csrfTokenRequired;
private array $defaultParams;
private array $allowedMethods;
private bool $externalCall;
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
$this->context = $context;
$this->logger = new Logger($this->getAPIName(), $this->context->getSQL());
$this->defaultParams = $params;
$this->success = false;
$this->result = array();
$this->externalCall = $externalCall;
$this->isPublic = true;
$this->isDisabled = false;
$this->loginRequired = false;
$this->variableParamCount = false;
$this->apiKeyAllowed = true;
$this->allowedMethods = array("GET", "POST");
$this->lastError = "";
$this->csrfTokenRequired = true;
}
public function getAPIName(): string {
if (get_class($this) === Request::class) {
return "API";
}
$reflection = new \ReflectionClass($this);
if ($reflection->getParentClass()->isAbstract() && $reflection->getParentClass()->isSubclassOf(Request::class)) {
return $reflection->getParentClass()->getShortName() . "/" . $reflection->getShortName();
} else {
return $reflection->getShortName();
}
}
protected function forbidMethod($method) {
if (($key = array_search($method, $this->allowedMethods)) !== false) {
unset($this->allowedMethods[$key]);
}
}
public function getDefaultParams(): array {
return $this->defaultParams;
}
public function isDisabled(): bool {
return $this->isDisabled;
}
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) {
$structure = $this->params;
}
foreach ($structure as $name => $param) {
$value = $values[$name] ?? NULL;
$isEmpty = (is_string($value) && strlen($value) === 0) || (is_array($value) && empty($value));
if (!$param->optional && (is_null($value) || $isEmpty)) {
return $this->createError("Missing parameter: $name");
}
$param->reset();
if (!is_null($value) && !$isEmpty) {
if (!$param->parseParam($value)) {
$value = print_r($value, true);
return $this->createError("Invalid Type for parameter: $name '$value' (Required: " . $param->getTypeName() . ")");
}
}
}
return true;
}
public function parseVariableParams($values) {
foreach ($values as $name => $value) {
if (isset($this->params[$name])) continue;
$type = Parameter\Parameter::parseType($value);
$param = new Parameter\Parameter($name, $type, true);
$param->parseParam($value);
$this->params[$name] = $param;
}
}
// wrapper for unit tests
protected function _die(string $data = ""): bool {
die($data);
}
protected abstract function _execute(): bool;
public final function execute($values = array()): bool {
$this->params = array_merge([], $this->defaultParams);
$this->success = false;
$this->result = array();
$this->lastError = '';
$session = $this->context->getSession();
if ($session) {
$this->result['logoutIn'] = $session->getExpiresSeconds();
}
if ($this->externalCall) {
$values = $_REQUEST;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SERVER["CONTENT_TYPE"]) && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"]))) {
$jsonData = json_decode(file_get_contents('php://input'), true);
if ($jsonData !== null) {
$values = array_merge($values, $jsonData);
} else {
$this->lastError = 'Invalid request body.';
http_response_code(400);
return false;
}
}
}
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.';
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';
http_response_code(405);
return false;
}
$apiKeyAuthorized = false;
if (!$session && $this->apiKeyAllowed) {
if (isset($_SERVER["HTTP_AUTHORIZATION"])) {
$authHeader = $_SERVER["HTTP_AUTHORIZATION"];
if (startsWith($authHeader, "Bearer ")) {
$apiKey = substr($authHeader, strlen("Bearer "));
$apiKeyAuthorized = $this->context->loadApiKey($apiKey);
}
}
}
// Logged in or api key authorized?
if ($this->loginRequired) {
if (!$session && !$apiKeyAuthorized) {
$this->lastError = 'You are not logged in.';
http_response_code(401);
return false;
} else if ($session) {
$tfaToken = $session->getUser()->getTwoFactorToken();
if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
$this->lastError = '2FA-Authorization is required';
http_response_code(401);
return false;
}
}
}
// CSRF Token
if ($this->csrfTokenRequired && $session) {
// csrf token required + external call
// if it's not a call with API_KEY, check for csrf_token
$csrfToken = $values["csrf_token"] ?? $_SERVER["HTTP_XSRF_TOKEN"] ?? null;
if (!$csrfToken || strcmp($csrfToken, $session->getCsrfToken()) !== 0) {
$this->lastError = "CSRF-Token mismatch";
http_response_code(403);
return false;
}
}
// Check for permission
if (!($this instanceof \API\Permission\Save)) {
$req = new \Core\API\Permission\Check($this->context);
$this->success = $req->execute(array("method" => $this->getMethod()));
$this->lastError = $req->getLastError();
if (!$this->success) {
return false;
}
}
}
if (!$this->parseParams($values)) {
return false;
}
if ($this->variableParamCount) {
$this->parseVariableParams($values);
}
$sql = $this->context->getSQL();
if (!$sql->isConnected()) {
$this->lastError = $sql->getLastError();
return false;
}
$this->success = true;
$success = $this->_execute();
if ($this->success !== $success) {
// _execute returns a different value then it set for $this->success
// this should actually not occur, how to handle this case?
$this->success = $success;
}
$sql->setLastError('');
return $this->success;
}
protected function createError($err): bool {
$this->success = false;
$this->lastError = $err;
return false;
}
protected function getParam($name, $obj = NULL) {
// i don't know why phpstorm
if ($obj === NULL) {
$obj = $this->params;
}
return (isset($obj[$name]) ? $obj[$name]->value : NULL);
}
public function isMethodAllowed(string $method): bool {
return in_array($method, $this->allowedMethods);
}
public function isPublic(): bool {
return $this->isPublic;
}
public function getLastError(): string {
return $this->lastError;
}
public function getResult(): array {
return $this->result;
}
public function success(): bool {
return $this->success;
}
public function loginRequired(): bool {
return $this->loginRequired;
}
public function isExternalCall(): bool {
return $this->externalCall;
}
private function getMethod() {
$class = str_replace("\\", "/", get_class($this));
$class = substr($class, strlen("api/"));
return $class;
}
public function getJsonResult(): string {
$this->result['success'] = $this->success;
$this->result['msg'] = $this->lastError;
return json_encode($this->result);
}
protected function disableOutputBuffer() {
ob_implicit_flush(true);
$levels = ob_get_level();
for ( $i = 0; $i < $levels; $i ++ ) {
ob_end_flush();
}
flush();
}
protected function disableCache() {
header("Last-Modified: " . (new \DateTime())->format("D, d M Y H:i:s T"));
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");
}
protected function setupSSE() {
$this->context->sendCookies();
$this->context->getSQL()?->close();
set_time_limit(0);
ignore_user_abort(true);
header('Content-Type: text/event-stream');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
$this->disableCache();
$this->disableOutputBuffer();
}
/**
* @throws \PhpMqtt\Client\Exceptions\ProtocolViolationException
* @throws \PhpMqtt\Client\Exceptions\DataTransferException
* @throws \PhpMqtt\Client\Exceptions\MqttClientException
*/
protected function startMqttSSE(MqttClient $mqtt, callable $onPing) {
$lastPing = 0;
$mqtt->registerLoopEventHandler(function(MqttClient $mqtt, $elapsed) use (&$lastPing, $onPing) {
if ($elapsed - $lastPing >= 5) {
$onPing();
$lastPing = $elapsed;
}
if (connection_status() !== 0) {
$mqtt->interrupt();
}
});
$mqtt->loop();
$this->lastError = "MQTT Loop disconnected";
$mqtt->disconnect();
}
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

@@ -0,0 +1,403 @@
<?php
namespace Core\API {
use Core\API\Routes\GenerateCache;
use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context;
abstract class RoutesAPI extends Request {
const ACTIONS = array("redirect_temporary", "redirect_permanently", "static", "dynamic");
const ROUTER_CACHE_CLASS = "\\Core\\Cache\\RouterCache";
protected string $routerCachePath;
public function __construct(Context $context, bool $externalCall, array $params) {
parent::__construct($context, $externalCall, $params);
$this->routerCachePath = getClassPath(self::ROUTER_CACHE_CLASS);
}
protected function routeExists($uid): bool {
$sql = $this->context->getSQL();
$res = $sql->select($sql->count())
->from("Route")
->where(new Compare("id", $uid))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success) {
if ($res[0]["count"] === 0) {
return $this->createError("Route not found");
}
}
return $this->success;
}
protected function toggleRoute($uid, $active): bool {
if (!$this->routeExists($uid)) {
return false;
}
$sql = $this->context->getSQL();
$this->success = $sql->update("Route")
->set("active", $active)
->where(new Compare("id", $uid))
->execute();
$this->lastError = $sql->getLastError();
$this->success = $this->success && $this->regenerateCache();
return $this->success;
}
protected function regenerateCache(): bool {
$req = new GenerateCache($this->context);
$this->success = $req->execute();
$this->lastError = $req->getLastError();
return $this->success;
}
}
}
namespace Core\API\Routes {
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\RoutesAPI;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondBool;
use Core\Objects\Context;
use Core\Objects\Router\DocumentRoute;
use Core\Objects\Router\RedirectRoute;
use Core\Objects\Router\Router;
use Core\Objects\Router\StaticFileRoute;
class Fetch extends RoutesAPI {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array());
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$res = $sql
->select("id", "request", "action", "target", "extra", "active", "exact")
->from("Route")
->orderBy("id")
->ascending()
->execute();
$this->lastError = $sql->getLastError();
$this->success = ($res !== FALSE);
if ($this->success) {
$routes = array();
foreach ($res as $row) {
$routes[] = array(
"id" => intval($row["id"]),
"request" => $row["request"],
"action" => $row["action"],
"target" => $row["target"],
"extra" => $row["extra"] ?? "",
"active" => intval($sql->parseBool($row["active"])),
"exact" => intval($sql->parseBool($row["exact"])),
);
}
$this->result["routes"] = $routes;
}
return $this->success;
}
}
class Save extends RoutesAPI {
private array $routes;
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array(
'routes' => new Parameter('routes', Parameter::TYPE_ARRAY, false)
));
}
public function _execute(): bool {
if (!$this->validateRoutes()) {
return false;
}
$sql = $this->context->getSQL();
// DELETE old rules
$this->success = ($sql->truncate("Route")->execute() !== FALSE);
$this->lastError = $sql->getLastError();
// INSERT new routes
if ($this->success) {
$stmt = $sql->insert("Route", array("request", "action", "target", "extra", "active", "exact"));
foreach ($this->routes as $route) {
$stmt->addRow($route["request"], $route["action"], $route["target"], $route["extra"], $route["active"], $route["exact"]);
}
$this->success = ($stmt->execute() !== FALSE);
$this->lastError = $sql->getLastError();
}
$this->success = $this->success && $this->regenerateCache();
return $this->success;
}
private function validateRoutes(): bool {
$this->routes = array();
$keys = array(
"request" => [Parameter::TYPE_STRING, Parameter::TYPE_INT],
"action" => Parameter::TYPE_STRING,
"target" => Parameter::TYPE_STRING,
"extra" => Parameter::TYPE_STRING,
"active" => Parameter::TYPE_BOOLEAN,
"exact" => Parameter::TYPE_BOOLEAN,
);
foreach ($this->getParam("routes") as $index => $route) {
foreach ($keys as $key => $expectedType) {
if (!array_key_exists($key, $route)) {
return $this->createError("Route $index missing key: $key");
}
$value = $route[$key];
$type = Parameter::parseType($value);
if (!is_array($expectedType)) {
$expectedType = [$expectedType];
}
if (!in_array($type, $expectedType)) {
if (count($expectedType) > 0) {
$expectedTypeName = "expected: " . Parameter::names[$expectedType];
} else {
$expectedTypeName = "expected one of: " . implode(",", array_map(
function ($type) {
return Parameter::names[$type];
}, $expectedType));
}
$gotTypeName = Parameter::names[$type];
return $this->createError("Route $index has invalid value for key: $key, $expectedTypeName, got: $gotTypeName");
}
}
$action = $route["action"];
if (!in_array($action, self::ACTIONS)) {
return $this->createError("Invalid action: $action");
}
if (empty($route["request"])) {
return $this->createError("Request cannot be empty.");
}
if (empty($route["target"])) {
return $this->createError("Target cannot be empty.");
}
$this->routes[] = $route;
}
return true;
}
}
class Add extends RoutesAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"request" => new StringType("request", 128),
"action" => new StringType("action"),
"target" => new StringType("target", 128),
"extra" => new StringType("extra", 64, true, ""),
));
$this->isPublic = false;
}
public function _execute(): bool {
$request = $this->getParam("request");
$action = $this->getParam("action");
$target = $this->getParam("target");
$extra = $this->getParam("extra");
if (!in_array($action, self::ACTIONS)) {
return $this->createError("Invalid action: $action");
}
$sql = $this->context->getSQL();
$this->success = $sql->insert("Route", ["request", "action", "target", "extra"])
->addRow($request, $action, $target, $extra)
->execute();
$this->lastError = $sql->getLastError();
$this->success = $this->success && $this->regenerateCache();
return $this->success;
}
}
class Update extends RoutesAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT),
"request" => new StringType("request", 128),
"action" => new StringType("action"),
"target" => new StringType("target", 128),
"extra" => new StringType("extra", 64, true, ""),
));
$this->isPublic = false;
}
public function _execute(): bool {
$id = $this->getParam("id");
if (!$this->routeExists($id)) {
return false;
}
$request = $this->getParam("request");
$action = $this->getParam("action");
$target = $this->getParam("target");
$extra = $this->getParam("extra");
if (!in_array($action, self::ACTIONS)) {
return $this->createError("Invalid action: $action");
}
$sql = $this->context->getSQL();
$this->success = $sql->update("Route")
->set("request", $request)
->set("action", $action)
->set("target", $target)
->set("extra", $extra)
->where(new Compare("id", $id))
->execute();
$this->lastError = $sql->getLastError();
$this->success = $this->success && $this->regenerateCache();
return $this->success;
}
}
class Remove extends RoutesAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT)
));
$this->isPublic = false;
}
public function _execute(): bool {
$uid = $this->getParam("id");
if (!$this->routeExists($uid)) {
return false;
}
$sql = $this->context->getSQL();
$this->success = $sql->delete("Route")
->where(new Compare("id", $uid))
->execute();
$this->lastError = $sql->getLastError();
$this->success = $this->success && $this->regenerateCache();
return $this->success;
}
}
class Enable extends RoutesAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT)
));
$this->isPublic = false;
}
public function _execute(): bool {
$uid = $this->getParam("id");
return $this->toggleRoute($uid, true);
}
}
class Disable extends RoutesAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT)
));
$this->isPublic = false;
}
public function _execute(): bool {
$uid = $this->getParam("id");
return $this->toggleRoute($uid, false);
}
}
class GenerateCache extends RoutesAPI {
private ?Router $router;
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
$this->isPublic = false;
$this->router = null;
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$res = $sql
->select("id", "request", "action", "target", "extra", "exact")
->from("Route")
->where(new CondBool("active"))
->orderBy("id")->ascending()
->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
$this->router = new Router($this->context);
foreach ($res as $row) {
$request = $row["request"];
$target = $row["target"];
$exact = $sql->parseBool($row["exact"]);
switch ($row["action"]) {
case "redirect_temporary":
$this->router->addRoute(new RedirectRoute($request, $exact, $target, 307));
break;
case "redirect_permanently":
$this->router->addRoute(new RedirectRoute($request, $exact, $target, 308));
break;
case "static":
$this->router->addRoute(new StaticFileRoute($request, $exact, $target));
break;
case "dynamic":
$extra = json_decode($row["extra"]) ?? [];
$this->router->addRoute(new DocumentRoute($request, $exact, $target, ...$extra));
break;
default:
break;
}
}
$this->success = $this->router->writeCache($this->routerCachePath);
if (!$this->success) {
return $this->createError("Error saving router cache file: " . $this->routerCachePath);
}
return $this->success;
}
public function getRouter(): ?Router {
return $this->router;
}
}
}

41
Core/API/Search.class.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace Core\API;
use Core\API\Parameter\StringType;
use Core\Objects\Context;
use Core\Objects\Search\Searchable;
use Core\Objects\Search\SearchQuery;
class Search extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, [
"text" => new StringType("text", 32)
]);
}
protected function _execute(): bool {
$query = new SearchQuery(trim($this->getParam("text")));
if (strlen($query->getQuery()) < 3) {
return $this->createError("You have to type at least 3 characters to search for");
}
$router = $this->context->router;
if ($router === null) {
return $this->createError("There is currently no router configured. Error during installation?");
}
$this->result["results"] = [];
foreach ($router->getRoutes(false) as $route) {
if(in_array(Searchable::class, array_keys((new \ReflectionClass($route))->getTraits()))) {
foreach ($route->doSearch($this->context, $query) as $searchResult) {
$this->result["results"][] = $searchResult->jsonSerialize();
}
}
}
return true;
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class SettingsAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
}
}
}
namespace Core\API\Settings {
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\SettingsAPI;
use Core\Configuration\Settings;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Condition\CondNot;
use Core\Driver\SQL\Condition\CondRegex;
use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\Context;
class Get extends SettingsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
'key' => new StringType('key', -1, true, NULL)
));
}
public function _execute(): bool {
$key = $this->getParam("key");
$sql = $this->context->getSQL();
$query = $sql->select("name", "value") ->from("Settings");
if (!is_null($key)) {
$query->where(new CondRegex(new Column("name"), $key));
}
// filter sensitive values, if called from outside
if ($this->isExternalCall()) {
$query->where(new CondNot("private"));
}
$res = $query->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
$settings = array();
foreach($res as $row) {
$settings[$row["name"]] = $row["value"];
}
$this->result["settings"] = $settings;
}
return $this->success;
}
}
class Set extends SettingsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
'settings' => new Parameter('settings', Parameter::TYPE_ARRAY)
));
}
public function _execute(): bool {
$values = $this->getParam("settings");
if (empty($values)) {
return $this->createError("No values given.");
}
$paramKey = new StringType('key', 32);
$paramValue = new StringType('value', 1024, true, NULL);
$sql = $this->context->getSQL();
$query = $sql->insert("Settings", array("name", "value"));
$keys = array();
$deleteKeys = array();
foreach($values as $key => $value) {
if (!$paramKey->parseParam($key)) {
$key = print_r($key, true);
return $this->createError("Invalid Type for key in parameter settings: '$key' (Required: " . $paramKey->getTypeName() . ")");
} else if(!is_null($value) && !$paramValue->parseParam($value)) {
$value = print_r($value, true);
return $this->createError("Invalid Type for value in parameter settings: '$value' (Required: " . $paramValue->getTypeName() . ")");
} else if(preg_match("/^[a-zA-Z_][a-zA-Z_0-9-]*$/", $paramKey->value) !== 1) {
return $this->createError("The property key should only contain alphanumeric characters, underscores and dashes");
} else {
if (!is_null($paramValue->value)) {
$query->addRow($paramKey->value, $paramValue->value);
} else {
$deleteKeys[] = $paramKey->value;
}
$keys[] = $paramKey->value;
}
}
if ($this->isExternalCall()) {
$column = $this->checkReadonly($keys);
if(!$this->success) {
return false;
} else if($column !== null) {
return $this->createError("Column '$column' is readonly.");
}
}
if (!empty($deleteKeys) && !$this->deleteKeys($keys)) {
return false;
}
if (count($deleteKeys) !== count($keys)) {
$query->onDuplicateKeyStrategy(new UpdateStrategy(
array("name"),
array("value" => new Column("value")))
);
$this->success = ($query->execute() !== FALSE);
$this->lastError = $sql->getLastError();
}
return $this->success;
}
private function checkReadonly(array $keys) {
$sql = $this->context->getSQL();
$res = $sql->select("name")
->from("Settings")
->where(new CondBool("readonly"))
->where(new CondIn(new Column("name"), $keys))
->first()
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success && $res !== null) {
return $res["name"];
}
return null;
}
private function deleteKeys(array $keys) {
$sql = $this->context->getSQL();
$res = $sql->delete("Settings")
->where(new CondIn(new Column("name"), $keys))
->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
return $this->success;
}
}
class GenerateJWT extends SettingsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"type" => new StringType("type", 32, true, "HS512")
]);
}
protected function _execute(): bool {
$algorithm = $this->getParam("type");
if (!Settings::isJwtAlgorithmSupported($algorithm)) {
return $this->createError("Algorithm is not supported");
}
$settings = $this->context->getSettings();
if (!$settings->generateJwtKey($algorithm)) {
return $this->createError("Error generating JWT-Key: " . $settings->getLogger()->getLastMessage());
}
$saveRequest = $settings->saveJwtKey($this->context);
if (!$saveRequest->success()) {
return $this->createError("Error saving JWT-Key: " . $saveRequest->getLastError());
}
$this->result["jwt_public_key"] = $settings->getJwtPublicKey(false)?->getKeyMaterial();
return true;
}
}
}

112
Core/API/Stats.class.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace Core\API;
use DateTime;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondBool;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\User;
class Stats extends Request {
private bool $mailConfigured;
private bool $recaptchaConfigured;
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, array());
}
private function getUserCount() {
$sql = $this->context->getSQL();
$res = $sql->select($sql->count())->from("User")->execute();
$this->success = $this->success && ($res !== FALSE);
$this->lastError = $sql->getLastError();
return ($this->success ? $res[0]["count"] : 0);
}
private function getPageCount() {
$sql = $this->context->getSQL();
$res = $sql->select($sql->count())->from("Route")
->where(new CondBool("active"))
->execute();
$this->success = $this->success && ($res !== FALSE);
$this->lastError = $sql->getLastError();
return ($this->success ? $res[0]["count"] : 0);
}
private function checkSettings(): bool {
$req = new \Core\API\Settings\Get($this->context);
$this->success = $req->execute(array("key" => "^(mail_enabled|recaptcha_enabled)$"));
$this->lastError = $req->getLastError();
if ($this->success) {
$settings = $req->getResult()["settings"];
$this->mailConfigured = ($settings["mail_enabled"] ?? "0") === "1";
$this->recaptchaConfigured = ($settings["recaptcha_enabled"] ?? "0") === "1";
}
return $this->success;
}
private function getVisitorCount() {
$sql = $this->context->getSQL();
$date = new DateTime();
$monthStart = $date->format("Ym00");
$monthEnd = $date->modify("+1 month")->format("Ym00");
$res = $sql->select($sql->count($sql->distinct("cookie")))
->from("Visitor")
->where(new Compare("day", $monthStart, ">="))
->where(new Compare("day", $monthEnd, "<"))
->where(new Compare("count", 2, ">="))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
return ($this->success ? $res[0]["count"] : $this->success);
}
public function _execute(): bool {
$userCount = $this->getUserCount();
$pageCount = $this->getPageCount();
$req = new \Core\API\Visitors\Stats($this->context);
$this->success = $req->execute(array("type"=>"monthly"));
$this->lastError = $req->getLastError();
if (!$this->success) {
return false;
}
$visitorStatistics = $req->getResult()["visitors"];
$visitorCount = $this->getVisitorCount();
if (!$this->success) {
return false;
}
$loadAvg = "Unknown";
if (function_exists("sys_getloadavg")) {
$loadAvg = sys_getloadavg();
}
if (!$this->checkSettings()) {
return false;
}
$this->result["userCount"] = $userCount;
$this->result["pageCount"] = $pageCount;
$this->result["visitors"] = $visitorStatistics;
$this->result["visitorsTotal"] = $visitorCount;
$this->result["server"] = array(
"version" => WEBBASE_VERSION,
"server" => $_SERVER["SERVER_SOFTWARE"] ?? "Unknown",
"memory_usage" => memory_get_usage(),
"load_avg" => $loadAvg,
"database" => $this->context->getSQL()->getStatus(),
"mail" => $this->mailConfigured,
"reCaptcha" => $this->recaptchaConfigured
);
return $this->success;
}
}

204
Core/API/Swagger.class.php Normal file
View File

@@ -0,0 +1,204 @@
<?php
namespace Core\API;
use Core\API\Parameter\StringType;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\User;
class Swagger extends Request {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, []);
$this->csrfTokenRequired = false;
}
public function _execute(): bool {
header("Content-Type: application/x-yaml");
header("Access-Control-Allow-Origin: *");
die($this->getDocumentation());
}
private function getApiEndpoints(): array {
// first load all direct classes
$classes = [];
$apiDirs = ["Core", "Site"];
foreach ($apiDirs as $apiDir) {
$basePath = realpath(WEBROOT . "/$apiDir/Api/");
if (!$basePath) {
continue;
}
foreach (scandir($basePath) as $fileName) {
$fullPath = $basePath . "/" . $fileName;
if (is_file($fullPath) && endsWith($fileName, ".class.php")) {
require_once $fullPath;
$apiName = explode(".", $fileName)[0];
$className = "\\API\\$apiName";
if (!class_exists($className)) {
var_dump("Class not exist: $className");
continue;
}
$reflection = new \ReflectionClass($className);
if (!$reflection->isSubclassOf(Request::class) || $reflection->isAbstract()) {
continue;
}
$endpoint = "/" . strtolower($apiName);
$classes[$endpoint] = $reflection;
}
}
}
// then load all inheriting classes
foreach (get_declared_classes() as $declaredClass) {
$reflectionClass = new \ReflectionClass($declaredClass);
if (!$reflectionClass->isAbstract() && $reflectionClass->isSubclassOf(Request::class)) {
$inheritingClass = $reflectionClass->getParentClass();
if ($inheritingClass->isAbstract() && endsWith($inheritingClass->getShortName(), "API")) {
$endpoint = strtolower(substr($inheritingClass->getShortName(), 0, -3));
$endpoint = "/$endpoint/" . lcfirst($reflectionClass->getShortName());
$classes[$endpoint] = $reflectionClass;
}
}
}
return $classes;
}
private function fetchPermissions(): array {
$req = new Permission\Fetch($this->context);
$this->success = $req->execute();
$permissions = [];
foreach( $req->getResult()["permissions"] as $permission) {
$permissions["/" . strtolower($permission["method"])] = $permission["groups"];
}
return $permissions;
}
private function canView(array $requiredGroups, Request $request): bool {
if (!$request->isPublic()) {
return false;
}
$currentUser = $this->context->getUser();
if (($request->loginRequired() || !empty($requiredGroups)) && !$currentUser) {
return false;
}
// special case: hardcoded permission
if ($request instanceof Permission\Save && (!$currentUser || !$currentUser->hasGroup(USER_GROUP_ADMIN))) {
return false;
}
if (!empty($requiredGroups)) {
$userGroups = array_keys($currentUser?->getGroups() ?? []);
return !empty(array_intersect($requiredGroups, $userGroups));
}
return true;
}
private function getDocumentation(): string {
$settings = $this->context->getSettings();
$siteName = $settings->getSiteName();
$domain = parse_url($settings->getBaseUrl(), PHP_URL_HOST);
$permissions = $this->fetchPermissions();
$definitions = [];
$paths = [];
foreach (self::getApiEndpoints() as $endpoint => $apiClass) {
$body = null;
$requiredProperties = [];
$apiObject = $apiClass->newInstance($this->context, false);
if (!$this->canView($permissions[strtolower($endpoint)] ?? [], $apiObject)) {
continue;
}
$parameters = $apiObject->getDefaultParams();
if (!empty($parameters)) {
$body = [];
foreach ($apiObject->getDefaultParams() as $param) {
$body[$param->name] = [
"type" => $param->getSwaggerTypeName(),
"default" => $param->value
];
if ($param instanceof StringType && $param->maxLength > 0) {
$body[$param->name]["maxLength"] = $param->maxLength;
}
if ($body[$param->name]["type"] === "string" && ($format = $param->getSwaggerFormat())) {
$body[$param->name]["format"] = $format;
}
if (!$param->optional) {
$requiredProperties[] = $param->name;
}
}
$bodyName = $apiClass->getShortName() . "Body";
$definitions[$bodyName] = [
"description" => "Body for $endpoint",
"properties" => $body
];
if (!empty($requiredProperties)) {
$definitions[$bodyName]["required"] = $requiredProperties;
}
}
$endPointDefinition = [
"post" => [
"produces" => ["application/json"],
"responses" => [
"200" => ["description" => ""],
"401" => ["description" => "Login or 2FA Authorization is required"],
]
]
];
if ($apiObject->isDisabled()) {
$endPointDefinition["post"]["deprecated"] = true;
}
if ($body) {
$endPointDefinition["post"]["consumes"] = ["application/json"];
$endPointDefinition["post"]["parameters"] = [[
"in" => "body",
"name" => "body",
"required" => !empty($requiredProperties),
"schema" => ["\$ref" => "#/definitions/" . $apiClass->getShortName() . "Body"]
]];
} else if ($apiObject->isMethodAllowed("GET")) {
$endPointDefinition["get"] = $endPointDefinition["post"];
unset($endPointDefinition["post"]);
}
$paths[$endpoint] = $endPointDefinition;
}
$yamlData = [
"swagger" => "2.0",
"info" => [
"description" => "This is the Backend API-Description of $siteName",
"version" => WEBBASE_VERSION,
"title" => $siteName,
"contact" => [ "email" => "webmaster@$domain" ],
],
"host" => $domain,
"basePath" => "/api",
"schemes" => ["https"],
"paths" => $paths,
"definitions" => $definitions
];
return \yaml_emit($yamlData);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class TemplateAPI extends Request {
function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
$this->isPublic = false; // internal API
}
}
}
namespace Core\API\Template {
use Core\API\Parameter\ArrayType;
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\TemplateAPI;
use Core\Objects\Context;
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(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"file" => new StringType("file"),
"parameters" => new ArrayType("parameters", Parameter::TYPE_MIXED, false, true, [])
]);
}
public function _execute(): bool {
$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/Cache/Templates/";
$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 true;
}
}
}

396
Core/API/TfaAPI.class.php Normal file
View File

@@ -0,0 +1,396 @@
<?php
namespace Core\API {
use Core\Objects\Context;
use Core\Objects\TwoFactor\AuthenticationData;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
abstract class TfaAPI extends Request {
private bool $userVerificationRequired;
public function __construct(Context $context, bool $externalCall = false, array $params = array()) {
parent::__construct($context, $externalCall, $params);
$this->loginRequired = true;
$this->apiKeyAllowed = false;
$this->userVerificationRequired = false;
}
protected function verifyAuthData(AuthenticationData $authData): bool {
$settings = $this->context->getSettings();
// $relyingParty = $settings->getSiteName();
$domain = parse_url($settings->getBaseUrl(), PHP_URL_HOST);
// $domain = "localhost";
if (!$authData->verifyIntegrity($domain)) {
return $this->createError("mismatched rpIDHash. expected: " . hash("sha256", $domain) . " got: " . bin2hex($authData->getHash()));
} else if (!$authData->isUserPresent()) {
return $this->createError("No user present");
} else if ($this->userVerificationRequired && !$authData->isUserVerified()) {
return $this->createError("user was not verified on device (PIN/Biometric/...)");
} else if ($authData->hasExtensionData()) {
return $this->createError("No extensions supported");
}
return true;
}
protected function verifyClientDataJSON($jsonData, KeyBasedTwoFactorToken $token): bool {
$settings = $this->context->getSettings();
$expectedType = $token->isConfirmed() ? "webauthn.get" : "webauthn.create";
$type = $jsonData["type"] ?? "null";
if ($type !== $expectedType) {
return $this->createError("Invalid client data json type. Expected: '$expectedType', Got: '$type'");
} else if ($token->getData() !== base64url_decode($jsonData["challenge"] ?? "")) {
return $this->createError("Challenge does not match");
} else if (($jsonData["origin"] ?? null) !== $settings->getBaseURL()) {
$baseUrl = $settings->getBaseURL();
return $this->createError("Origin does not match. Expected: '$baseUrl', Got: '${$jsonData["origin"]}'");
}
return true;
}
}
}
namespace Core\API\TFA {
use Core\API\Parameter\StringType;
use Core\API\TfaAPI;
use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context;
use Core\Objects\TwoFactor\AttestationObject;
use Core\Objects\TwoFactor\AuthenticationData;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use Core\Objects\TwoFactor\TimeBasedTwoFactorToken;
// General
class Remove extends TfaAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"password" => new StringType("password", 0, true)
]);
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$token = $currentUser->getTwoFactorToken();
if (!$token) {
return $this->createError("You do not have an active 2FA-Token");
}
$sql = $this->context->getSQL();
$password = $this->getParam("password");
if ($password) {
$res = $sql->select("password")
->from("User")
->where(new Compare("id", $currentUser->getId()))
->execute();
$this->success = !empty($res);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
} else if (!password_verify($password, $res[0]["password"])) {
return $this->createError("Wrong password");
}
} else if ($token->isConfirmed()) {
// if the token is fully confirmed, require a password to remove it
return $this->createError("Missing parameter: password");
}
$res = $sql->delete("2FA")
->where(new Compare("id", $token->getId()))
->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
if ($this->success && $token->isConfirmed()) {
// send an email
$settings = $this->context->getSettings();
$req = new \Core\API\Template\Render($this->context);
$this->success = $req->execute([
"file" => "mail/2fa_remove.twig",
"parameters" => [
"username" => $currentUser->getFullName() ?? $currentUser->getUsername(),
"site_name" => $settings->getSiteName(),
"sender_mail" => $settings->getMailSender()
]
]);
if ($this->success) {
$body = $req->getResult()["html"];
$gpg = $currentUser->getGPG();
$req = new \Core\API\Mail\Send($this->context);
$this->success = $req->execute([
"to" => $currentUser->getEmail(),
"subject" => "[Security Lab] 2FA-Authentication removed",
"body" => $body,
"gpgFingerprint" => $gpg?->getFingerprint()
]);
}
$this->lastError = $req->getLastError();
}
return $this->success;
}
}
// TOTP
class GenerateQR extends TfaAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall);
$this->csrfTokenRequired = false;
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$twoFactorToken = $currentUser->getTwoFactorToken();
if ($twoFactorToken && $twoFactorToken->isConfirmed()) {
return $this->createError("You already added a two factor token");
} else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) {
$twoFactorToken = new TimeBasedTwoFactorToken(generateRandomString(32, "base32"));
$sql = $this->context->getSQL();
$this->success = $sql->insert("2FA", ["type", "data"])
->addRow("totp", $twoFactorToken->getData())
->returning("id")
->execute() !== false;
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->success = $sql->update("User")
->set("2fa_id", $sql->getLastInsertId())->where(new Compare("id", $currentUser->getId()))
->execute() !== false;
$this->lastError = $sql->getLastError();
}
if (!$this->success) {
return false;
}
}
header("Content-Type: image/png");
$this->disableCache();
die($twoFactorToken->generateQRCode($this->context));
}
}
class ConfirmTotp extends VerifyTotp {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall);
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$twoFactorToken = $currentUser->getTwoFactorToken();
if ($twoFactorToken->isConfirmed()) {
return $this->createError("Your two factor token is already confirmed.");
}
$sql = $this->context->getSQL();
$this->success = $sql->update("2FA")
->set("confirmed", true)
->where(new Compare("id", $twoFactorToken->getId()))
->execute() !== false;
$this->lastError = $sql->getLastError();
return $this->success;
}
}
class VerifyTotp extends TfaAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"code" => new StringType("code", 6)
]);
$this->loginRequired = true;
$this->csrfTokenRequired = false;
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
if (!$currentUser) {
return $this->createError("You are not logged in.");
}
$twoFactorToken = $currentUser->getTwoFactorToken();
if (!$twoFactorToken) {
return $this->createError("You did not add a two factor token yet.");
} else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) {
return $this->createError("Invalid 2FA-token endpoint");
}
$code = $this->getParam("code");
if (!$twoFactorToken->verify($code)) {
return $this->createError("Code does not match");
}
$twoFactorToken->authenticate();
return $this->success;
}
}
// Key
class RegisterKey extends TfaAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"clientDataJSON" => new StringType("clientDataJSON", 0, true, "{}"),
"attestationObject" => new StringType("attestationObject", 0, true, "")
]);
$this->loginRequired = true;
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
$clientDataJSON = json_decode($this->getParam("clientDataJSON"), true);
$attestationObjectRaw = base64_decode($this->getParam("attestationObject"));
$twoFactorToken = $currentUser->getTwoFactorToken();
$settings = $this->context->getSettings();
$relyingParty = $settings->getSiteName();
$sql = $this->context->getSQL();
// TODO: for react development, localhost / HTTP_HOST is required, otherwise a DOMException is thrown
$domain = parse_url($settings->getBaseUrl(), PHP_URL_HOST);
// $domain = "localhost";
if (!$clientDataJSON || !$attestationObjectRaw) {
if ($twoFactorToken) {
if (!($twoFactorToken instanceof KeyBasedTwoFactorToken) || $twoFactorToken->isConfirmed()) {
return $this->createError("You already added a two factor token");
} else {
$challenge = base64_encode($twoFactorToken->getData());
}
} else {
$challenge = base64_encode(generateRandomString(32, "raw"));
$res = $sql->insert("2FA", ["type", "data"])
->addRow("fido", $challenge)
->returning("id")
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
$this->success = $sql->update("User")
->set("2fa_id", $sql->getLastInsertId())
->where(new Compare("id", $currentUser->getId()))
->execute() !== false;
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
}
$this->result["data"] = [
"challenge" => $challenge,
"id" => $currentUser->getId() . "@" . $domain, // <userId>@<domain>
"relyingParty" => [
"name" => $relyingParty,
"id" => $domain
],
];
} else {
if ($twoFactorToken === null) {
return $this->createError("Request a registration first.");
} else if (!($twoFactorToken instanceof KeyBasedTwoFactorToken)) {
return $this->createError("You already got a 2FA token");
}
if (!$this->verifyClientDataJSON($clientDataJSON, $twoFactorToken)) {
return false;
}
$attestationObject = new AttestationObject($attestationObjectRaw);
$authData = $attestationObject->getAuthData();
if (!$this->verifyAuthData($authData)) {
return false;
}
$publicKey = $authData->getPublicKey();
if ($publicKey->getUsedAlgorithm() !== -7) {
return $this->createError("Unsupported key type. Expected: -7");
}
$data = [
"credentialID" => base64_encode($authData->getCredentialID()),
"publicKey" => $publicKey->jsonSerialize()
];
$this->success = $sql->update("2FA")
->set("data", json_encode($data))
->set("confirmed", true)
->where(new Compare("id", $twoFactorToken->getId()))
->execute() !== false;
$this->lastError = $sql->getLastError();
}
return $this->success;
}
}
class VerifyKey extends TfaAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"credentialID" => new StringType("credentialID"),
"clientDataJSON" => new StringType("clientDataJSON"),
"authData" => new StringType("authData"),
"signature" => new StringType("signature"),
]);
$this->loginRequired = true;
$this->csrfTokenRequired = false;
}
public function _execute(): bool {
$currentUser = $this->context->getUser();
if (!$currentUser) {
return $this->createError("You are not logged in.");
}
$twoFactorToken = $currentUser->getTwoFactorToken();
if (!$twoFactorToken) {
return $this->createError("You did not add a two factor token yet.");
} else if (!($twoFactorToken instanceof KeyBasedTwoFactorToken)) {
return $this->createError("Invalid 2FA-token endpoint");
} else if (!$twoFactorToken->isConfirmed()) {
return $this->createError("2FA-Key not confirmed yet");
}
$credentialID = base64url_decode($this->getParam("credentialID"));
if ($credentialID !== $twoFactorToken->getCredentialId()) {
return $this->createError("credential ID does not match");
}
$jsonData = $this->getParam("clientDataJSON");
if (!$this->verifyClientDataJSON(json_decode($jsonData, true), $twoFactorToken)) {
return false;
}
$authDataRaw = base64_decode($this->getParam("authData"));
$authData = new AuthenticationData($authDataRaw);
if (!$this->verifyAuthData($authData)) {
return false;
}
$clientDataHash = hash("sha256", $jsonData, true);
$signature = base64_decode($this->getParam("signature"));
$this->success = $twoFactorToken->verify($signature, $authDataRaw . $clientDataHash);
if ($this->success) {
$twoFactorToken->authenticate();
} else {
$this->lastError = "Verification failed";
}
return $this->success;
}
}
}

1866
Core/API/UserAPI.class.php Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
<?php
namespace Core\API;
use Core\API\Parameter\StringType;
use Core\Objects\Context;
class VerifyCaptcha extends Request {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"captcha" => new StringType("captcha"),
"action" => new StringType("action"),
));
$this->isPublic = false;
}
public function _execute(): bool {
$settings = $this->context->getSettings();
if (!$settings->isRecaptchaEnabled()) {
return $this->createError("Google reCaptcha is not enabled.");
}
$url = "https://www.google.com/recaptcha/api/siteverify";
$secret = $settings->getRecaptchaSecretKey();
$captcha = $this->getParam("captcha");
$action = $this->getParam("action");
$params = array(
"secret" => $secret,
"response" => $captcha
);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = @json_decode(curl_exec($ch), true);
curl_close($ch);
$this->success = false;
$this->lastError = "Could not verify captcha: No response from google received.";
if ($response) {
$this->success = $response["success"];
if (!$this->success) {
$this->lastError = "Could not verify captcha: " . implode(";", $response["error-codes"]);
} else {
$score = $response["score"];
if ($action !== $response["action"]) {
$this->createError("Could not verify captcha: Action does not match");
} else if ($score < 0.7) {
$this->createError("Could not verify captcha: Google ReCaptcha Score < 0.7 (Your score: $score), you are likely a bot");
}
}
}
return $this->success;
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Core\API {
use Core\Objects\Context;
abstract class VisitorsAPI extends Request {
public function __construct(Context $context, bool $externalCall = false, array $params = []) {
parent::__construct($context, $externalCall, $params);
}
}
}
namespace Core\API\Visitors {
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\VisitorsAPI;
use DateTime;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Expression\Add;
use Core\Driver\SQL\Query\Select;
use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\Context;
class ProcessVisit extends VisitorsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
"cookie" => new StringType("cookie")
));
$this->isPublic = false;
}
public function _execute(): bool {
$sql = $this->context->getSQL();
$cookie = $this->getParam("cookie");
$day = (new DateTime())->format("Ymd");
$sql->insert("Visitor", array("cookie", "day"))
->addRow($cookie, $day)
->onDuplicateKeyStrategy(new UpdateStrategy(
array("day", "cookie"),
array("count" => new Add("Visitor.count", 1))))
->execute();
return $this->success;
}
}
class Stats extends VisitorsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array(
'type' => new StringType('type', 32),
'date' => new Parameter('date', Parameter::TYPE_DATE, true, new DateTime())
));
}
private function setConditions(string $type, DateTime $date, Select $query): bool {
if ($type === "yearly") {
$yearStart = $date->format("Y0000");
$yearEnd = $date->modify("+1 year")->format("Y0000");
$query->where(new Compare("day", $yearStart, ">="));
$query->where(new Compare("day", $yearEnd, "<"));
} else if($type === "monthly") {
$monthStart = $date->format("Ym00");
$monthEnd = $date->modify("+1 month")->format("Ym00");
$query->where(new Compare("day", $monthStart, ">="));
$query->where(new Compare("day", $monthEnd, "<"));
} else if($type === "weekly") {
$weekStart = ($date->modify("monday this week"))->format("Ymd");
$weekEnd = ($date->modify("sunday this week"))->format("Ymd");
$query->where(new Compare("day", $weekStart, ">="));
$query->where(new Compare("day", $weekEnd, "<="));
} else {
return $this->createError("Invalid scope: $type");
}
return true;
}
public function _execute(): bool {
$date = $this->getParam("date");
$type = $this->getParam("type");
$sql = $this->context->getSQL();
$query = $sql->select($sql->count(), "day")
->from("Visitor")
->where(new Compare("count", 1, ">"))
->groupBy("day")
->orderBy("day")
->ascending();
$this->success = $this->setConditions($type, $date, $query);
if (!$this->success) {
return false;
}
$res = $query->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["type"] = $type;
$this->result["visitors"] = array();
foreach($res as $row) {
$day = DateTime::createFromFormat("Ymd", $row["day"])->format("Y/m/d");
$count = $row["count"];
$this->result["visitors"][$day] = $count;
}
}
return $this->success;
}
}
}