v2.0-alpha

This commit is contained in:
2022-06-20 19:52:31 +02:00
parent b549af3166
commit ce647d4423
78 changed files with 2474 additions and 2083 deletions

View File

@@ -0,0 +1,202 @@
<?php
namespace Objects;
use Configuration\Configuration;
use Configuration\Settings;
use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\CondLike;
use Driver\SQL\Condition\CondOr;
use Driver\SQL\SQL;
use Firebase\JWT\JWT;
use Objects\DatabaseEntity\Language;
use Objects\DatabaseEntity\Session;
use Objects\DatabaseEntity\User;
class Context {
private ?SQL $sql;
private ?Session $session;
private ?User $user;
private Configuration $configuration;
private Language $language;
public function __construct() {
$this->sql = null;
$this->session = null;
$this->user = null;
$this->configuration = new Configuration();
$this->setLanguage(Language::DEFAULT_LANGUAGE());
if (!$this->isCLI()) {
@session_start();
}
}
public function __destruct() {
if ($this->sql && $this->sql->isConnected()) {
$this->sql->close();
$this->sql = null;
}
}
public function setLanguage(Language $language) {
$this->language = $language;
$this->language->activate();
if ($this->user && $this->user->language->getId() !== $language->getId()) {
$this->user->language = $language;
}
}
public function initSQL(): ?SQL {
$databaseConf = $this->configuration->getDatabase();
if ($databaseConf) {
$this->sql = SQL::createConnection($databaseConf);
if ($this->sql->isConnected()) {
$settings = $this->configuration->getSettings();
$settings->loadFromDatabase($this);
return $this->sql;
}
} else {
$this->sql = null;
}
return null;
}
public function getSQL(): ?SQL {
return $this->sql;
}
public function getSettings(): Settings {
return $this->configuration->getSettings();
}
public function getUser(): ?User {
return $this->user;
}
public function sendCookies() {
$domain = $this->getSettings()->getDomain();
$this->language->sendCookie($domain);
$this->session?->sendCookie($domain);
$this->session?->update();
session_write_close();
}
private function loadSession(int $userId, int $sessionId) {
$this->session = Session::init($this, $userId, $sessionId);
$this->user = $this->session?->getUser();
}
public function parseCookies() {
if (isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
try {
$token = $_COOKIE['session'];
$settings = $this->configuration->getSettings();
$decoded = (array)JWT::decode($token, $settings->getJwtKey());
if (!is_null($decoded)) {
$userId = ($decoded['userId'] ?? NULL);
$sessionId = ($decoded['sessionId'] ?? NULL);
if (!is_null($userId) && !is_null($sessionId)) {
$this->loadSession($userId, $sessionId);
}
}
} catch (\Exception $e) {
// ignored
}
}
// set language by priority: 1. GET parameter, 2. cookie, 3. user's settings
if (isset($_GET['lang']) && is_string($_GET["lang"]) && !empty($_GET["lang"])) {
$this->updateLanguage($_GET['lang']);
} else if (isset($_COOKIE['lang']) && is_string($_COOKIE["lang"]) && !empty($_COOKIE["lang"])) {
$this->updateLanguage($_COOKIE['lang']);
} else if ($this->user) {
$this->setLanguage($this->user->language);
}
}
public function updateLanguage(string $lang): bool {
if ($this->sql) {
$language = Language::findBuilder($this->sql)
->where(new CondOr(
new CondLike("name", "%$lang%"), // english
new Compare("code", $lang), // de_DE
new CondLike("code", $lang . "_%"))) // de -> de_%
->execute();
if ($language) {
$this->setLanguage($language);
return true;
}
}
return false;
}
public function processVisit() {
if (isset($_COOKIE["PHPSESSID"]) && !empty($_COOKIE["PHPSESSID"])) {
if ($this->isBot()) {
return;
}
$cookie = $_COOKIE["PHPSESSID"];
$req = new \Api\Visitors\ProcessVisit($this);
$req->execute(["cookie" => $cookie]);
}
}
private function isBot(): bool {
if (empty($_SERVER["HTTP_USER_AGENT"])) {
return false;
}
return preg_match('/robot|spider|crawler|curl|^$/i', $_SERVER['HTTP_USER_AGENT']) === 1;
}
public function isCLI(): bool {
return php_sapi_name() === "cli";
}
public function getConfig(): Configuration {
return $this->configuration;
}
public function getSession(): ?Session {
return $this->session;
}
public function loadApiKey(string $apiKey): bool {
$this->user = User::findBuilder($this->sql)
->addJoin(new \Driver\SQL\Join("INNER","ApiKey", "ApiKey.user_id", "User.id"))
->where(new Compare("ApiKey.api_key", $apiKey))
->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">"))
->where(new Compare("ApiKey.active", true))
->where(new Compare("User.confirmed", true))
->fetchEntities()
->execute();
return $this->user !== null;
}
public function createSession(int $userId, bool $stayLoggedIn): ?Session {
$this->user = User::find($this->sql, $userId);
if ($this->user) {
$this->session = new Session($this, $this->user);
$this->session->stayLoggedIn = $stayLoggedIn;
if ($this->session->update()) {
return $this->session;
}
}
$this->user = null;
$this->session = null;
return null;
}
public function getLanguage(): Language {
return $this->language;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Objects\DatabaseEntity;
use Objects\DatabaseEntity\Attribute\MaxLength;
class ApiKey extends DatabaseEntity {
private bool $active;
#[MaxLength(64)] public String $apiKey;
public \DateTime $validUntil;
public User $user;
public function __construct(?int $id = null) {
parent::__construct($id);
$this->active = true;
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"active" => $this->active,
"apiKey" => $this->apiKey,
"validUntil" => $this->validUntil->getTimestamp()
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)] class DefaultValue {
private mixed $value;
public function __construct(mixed $value) {
$this->value = $value;
}
public function getValue() {
if (is_string($this->value) && isClass($this->value)) {
return new $this->value();
}
return $this->value;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)] class Enum {
private array $values;
public function __construct(string ...$values) {
$this->values = $values;
}
public function getValues(): array {
return $this->values;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)] class Json {
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)] class Many {
private string $type;
public function __construct(string $type) {
$this->type = $type;
}
public function getValue(): string {
return $this->type;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)] class MaxLength {
private int $maxLength;
function __construct(int $maxLength) {
$this->maxLength = $maxLength;
}
public function getValue(): int {
return $this->maxLength;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)] class Transient {
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)] class Unique {
}

View File

@@ -2,20 +2,57 @@
namespace Objects\DatabaseEntity;
use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\Condition;
use Driver\SQL\SQL;
abstract class DatabaseEntity {
private static array $handlers = [];
private ?int $id = null;
protected ?int $id;
public function __construct() {
public function __construct(?int $id = null) {
$this->id = $id;
}
public static function find(SQL $sql, int $id): ?DatabaseEntity {
public abstract function jsonSerialize(): array;
public function preInsert(array &$row) { }
public function postFetch(SQL $sql, array $row) { }
public static function fromRow(SQL $sql, array $row): static {
$handler = self::getHandler($sql);
return $handler->fetchOne($id);
return $handler->entityFromRow($row);
}
public static function newInstance(\ReflectionClass $reflectionClass, array $row) {
return $reflectionClass->newInstanceWithoutConstructor();
}
public static function find(SQL $sql, int $id, bool $fetchEntities = false, bool $fetchRecursive = false): static|bool|null {
$handler = self::getHandler($sql);
if ($fetchEntities) {
return DatabaseEntityQuery::fetchOne(self::getHandler($sql))
->where(new Compare($handler->getTableName() . ".id", $id))
->fetchEntities($fetchRecursive)
->execute();
} else {
return $handler->fetchOne($id);
}
}
public static function exists(SQL $sql, int $id): bool {
$handler = self::getHandler($sql);
$res = $sql->select($sql->count())
->from($handler->getTableName())
->where(new Compare($handler->getTableName() . ".id", $id))
->execute();
return $res !== false && $res[0]["count"] !== 0;
}
public static function findBuilder(SQL $sql): DatabaseEntityQuery {
return DatabaseEntityQuery::fetchOne(self::getHandler($sql));
}
public static function findAll(SQL $sql, ?Condition $condition = null): ?array {
@@ -23,9 +60,13 @@ abstract class DatabaseEntity {
return $handler->fetchMultiple($condition);
}
public function save(SQL $sql): bool {
public static function findAllBuilder(SQL $sql): DatabaseEntityQuery {
return DatabaseEntityQuery::fetchAll(self::getHandler($sql));
}
public function save(SQL $sql, ?array $columns = null): bool {
$handler = self::getHandler($sql);
$res = $handler->insertOrUpdate($this);
$res = $handler->insertOrUpdate($this, $columns);
if ($res === false) {
return false;
} else if ($this->id === null) {

View File

@@ -5,7 +5,9 @@ namespace Objects\DatabaseEntity;
use Driver\Logger\Logger;
use Driver\SQL\Column\BoolColumn;
use Driver\SQL\Column\DateTimeColumn;
use Driver\SQL\Column\EnumColumn;
use Driver\SQL\Column\IntColumn;
use Driver\SQL\Column\JsonColumn;
use Driver\SQL\Column\StringColumn;
use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\Condition;
@@ -13,19 +15,27 @@ use Driver\SQL\Column\DoubleColumn;
use Driver\SQL\Column\FloatColumn;
use Driver\SQL\Constraint\ForeignKey;
use Driver\SQL\Query\CreateTable;
use Driver\SQL\Query\Select;
use Driver\SQL\SQL;
use Driver\SQL\Strategy\CascadeStrategy;
use Driver\SQL\Strategy\SetNullStrategy;
use Objects\DatabaseEntity\Attribute\Enum;
use Objects\DatabaseEntity\Attribute\DefaultValue;
use Objects\DatabaseEntity\Attribute\Json;
use Objects\DatabaseEntity\Attribute\Many;
use Objects\DatabaseEntity\Attribute\MaxLength;
use Objects\DatabaseEntity\Attribute\Transient;
use Objects\DatabaseEntity\Attribute\Unique;
use PHPUnit\Util\Exception;
class DatabaseEntityHandler {
private \ReflectionClass $entityClass;
private static \ReflectionProperty $ID_FIELD;
private string $tableName;
private array $columns;
private array $properties;
private array $relations;
private array $constraints;
private SQL $sql;
private Logger $logger;
@@ -34,22 +44,23 @@ class DatabaseEntityHandler {
$className = $entityClass->getName();
$this->logger = new Logger($entityClass->getShortName(), $sql);
$this->entityClass = $entityClass;
if (!$this->entityClass->isSubclassOf(DatabaseEntity::class) ||
!$this->entityClass->isInstantiable()) {
if (!$this->entityClass->isSubclassOf(DatabaseEntity::class)) {
$this->raiseError("Cannot persist class '$className': Not an instance of DatabaseEntity or not instantiable.");
}
$this->tableName = $this->entityClass->getShortName();
$this->columns = [];
$this->properties = [];
$this->relations = [];
if (!isset(self::$ID_FIELD)) {
self::$ID_FIELD = (new \ReflectionClass(DatabaseEntity::class))->getProperty("id");
}
$this->columns = []; // property name => database column name
$this->properties = []; // property name => \ReflectionProperty
$this->relations = []; // property name => referenced table name
$this->constraints = []; // \Driver\SQL\Constraint\Constraint
foreach ($this->entityClass->getProperties() as $property) {
$propertyName = $property->getName();
if ($propertyName === "id") {
$this->properties[$propertyName] = $property;
continue;
}
$propertyType = $property->getType();
$columnName = self::getColumnName($propertyName);
if (!($propertyType instanceof \ReflectionNamedType)) {
@@ -58,38 +69,83 @@ class DatabaseEntityHandler {
$nullable = $propertyType->allowsNull();
$propertyTypeName = $propertyType->getName();
if (!empty($property->getAttributes(Transient::class))) {
continue;
}
$defaultValue = (self::getAttribute($property, DefaultValue::class))?->getValue();
$isUnique = !empty($property->getAttributes(Unique::class));
if ($propertyTypeName === 'string') {
$this->columns[$propertyName] = new StringColumn($columnName, null, $nullable);
$enum = self::getAttribute($property, Enum::class);
if ($enum) {
$this->columns[$propertyName] = new EnumColumn($columnName, $enum->getValues(), $nullable, $defaultValue);
} else {
$maxLength = self::getAttribute($property, MaxLength::class);
$this->columns[$propertyName] = new StringColumn($columnName, $maxLength?->getValue(), $nullable, $defaultValue);
}
} else if ($propertyTypeName === 'int') {
$this->columns[$propertyName] = new IntColumn($columnName, $nullable);
$this->columns[$propertyName] = new IntColumn($columnName, $nullable, $defaultValue);
} else if ($propertyTypeName === 'float') {
$this->columns[$propertyName] = new FloatColumn($columnName, $nullable);
$this->columns[$propertyName] = new FloatColumn($columnName, $nullable, $defaultValue);
} else if ($propertyTypeName === 'double') {
$this->columns[$propertyName] = new DoubleColumn($columnName, $nullable);
$this->columns[$propertyName] = new DoubleColumn($columnName, $nullable, $defaultValue);
} else if ($propertyTypeName === 'bool') {
$this->columns[$propertyName] = new BoolColumn($columnName, $nullable);
$this->columns[$propertyName] = new BoolColumn($columnName, $defaultValue ?? false);
} else if ($propertyTypeName === 'DateTime') {
$this->columns[$propertyName] = new DateTimeColumn($columnName, $nullable);
} else {
$this->columns[$propertyName] = new DateTimeColumn($columnName, $nullable, $defaultValue);
/*} else if ($propertyName === 'array') {
$many = self::getAttribute($property, Many::class);
if ($many) {
$requestedType = $many->getValue();
if (isClass($requestedType)) {
$requestedClass = new \ReflectionClass($requestedType);
} else {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $requestedType");
}
} else {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $propertyTypeName");
}*/
} else if ($propertyTypeName !== "mixed") {
try {
$requestedClass = new \ReflectionClass($propertyTypeName);
if ($requestedClass->isSubclassOf(DatabaseEntity::class)) {
$columnName .= "_id";
$requestedHandler = ($requestedClass->getName() === $this->entityClass->getName()) ?
$this : DatabaseEntity::getHandler($this->sql, $requestedClass);
$strategy = $nullable ? new SetNullStrategy() : new CascadeStrategy();
$this->columns[$propertyName] = new IntColumn($columnName, $nullable);
$this->relations[$propertyName] = new ForeignKey($columnName, $requestedHandler->tableName, "id", $strategy);
$this->columns[$propertyName] = new IntColumn($columnName, $nullable, $defaultValue);
$this->constraints[] = new ForeignKey($columnName, $requestedHandler->tableName, "id", $strategy);
$this->relations[$propertyName] = $requestedHandler;
} else {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $propertyTypeName");
}
} catch (\Exception $ex) {
$this->raiseError("Cannot persist class '$className' property '$propertyTypeName': " . $ex->getMessage());
}
} else {
if (!empty($property->getAttributes(Json::class))) {
$this->columns[$propertyName] = new JsonColumn($columnName, $nullable, $defaultValue);
} else {
$this->raiseError("Cannot persist class '$className': Property '$propertyName' has non persist-able type: $propertyTypeName");
}
}
$this->properties[$propertyName] = $property;
if ($isUnique) {
$this->constraints[] = new \Driver\SQL\Constraint\Unique($columnName);
}
}
}
private static function getColumnName(string $propertyName): string {
private static function getAttribute(\ReflectionProperty $property, string $attributeClass): ?object {
$attributes = $property->getAttributes($attributeClass);
$attribute = array_shift($attributes);
return $attribute?->newInstance();
}
public static function getColumnName(string $propertyName): string {
// abcTestLOL => abc_test_lol
return strtolower(preg_replace_callback("/([a-z])([A-Z]+)/", function ($m) {
return $m[1] . "_" . strtolower($m[2]);
@@ -108,46 +164,111 @@ class DatabaseEntityHandler {
return $this->tableName;
}
private function entityFromRow(array $row): DatabaseEntity {
public function getRelations(): array {
return $this->relations;
}
public function getColumnNames(): array {
$columns = ["$this->tableName.id"];
foreach ($this->columns as $column) {
$columns[] = $this->tableName . "." . $column->getName();
}
return $columns;
}
public function getColumns(): array {
return $this->columns;
}
public function dependsOn(): array {
$foreignTables = array_map(function (DatabaseEntityHandler $relationHandler) {
return $relationHandler->getTableName();
}, $this->relations);
return array_unique($foreignTables);
}
public static function getPrefixedRow(array $row, string $prefix): array {
$rel_row = [];
foreach ($row as $relKey => $relValue) {
if (startsWith($relKey, $prefix)) {
$rel_row[substr($relKey, strlen($prefix))] = $relValue;
}
}
return $rel_row;
}
public function entityFromRow(array $row): ?DatabaseEntity {
try {
$entity = $this->entityClass->newInstanceWithoutConstructor();
foreach ($this->columns as $propertyName => $column) {
$value = $row[$column->getName()];
$property = $this->properties[$propertyName];
if ($property->getType()->getName() === "DateTime") {
$value = new \DateTime($value);
}
$property->setValue($entity, $value);
$entity = call_user_func($this->entityClass->getName() . "::newInstance", $this->entityClass, $row);
if (!($entity instanceof DatabaseEntity)) {
$this->logger->error("Created Object is not of type DatabaseEntity");
return null;
}
self::$ID_FIELD->setAccessible(true);
self::$ID_FIELD->setValue($entity, $row["id"]);
foreach ($this->columns as $propertyName => $column) {
$columnName = $column->getName();
if (array_key_exists($columnName, $row)) {
$value = $row[$columnName];
$property = $this->properties[$propertyName];
if ($column instanceof DateTimeColumn) {
$value = new \DateTime($value);
} else if ($column instanceof JsonColumn) {
$value = json_decode($value);
} else if (isset($this->relations[$propertyName])) {
$relColumnPrefix = self::getColumnName($propertyName) . "_";
if (array_key_exists($relColumnPrefix . "id", $row)) {
$relId = $row[$relColumnPrefix . "id"];
if ($relId !== null) {
$relationHandler = $this->relations[$propertyName];
$value = $relationHandler->entityFromRow(self::getPrefixedRow($row, $relColumnPrefix));
} else if (!$column->notNull()) {
$value = null;
} else {
continue;
}
} else {
continue;
}
}
$property->setAccessible(true);
$property->setValue($entity, $value);
}
}
$this->properties["id"]->setAccessible(true);
$this->properties["id"]->setValue($entity, $row["id"]);
$entity->postFetch($this->sql, $row);
return $entity;
} catch (\Exception $exception) {
$this->logger->error("Error creating entity from database row: " . $exception->getMessage());
throw $exception;
return null;
}
}
public function fetchOne(int $id): ?DatabaseEntity {
$res = $this->sql->select("id", ...array_keys($this->columns))
->from($this->tableName)
->where(new Compare("id", $id))
public function getSelectQuery(): Select {
return $this->sql->select(...$this->getColumnNames())
->from($this->tableName);
}
public function fetchOne(int $id): DatabaseEntity|bool|null {
$res = $this->getSelectQuery()
->where(new Compare($this->tableName . ".id", $id))
->first()
->execute();
if (empty($res)) {
return null;
if ($res === false || $res === null) {
return $res;
} else {
return $this->entityFromRow($res);
}
}
public function fetchMultiple(?Condition $condition = null): ?array {
$query = $this->sql->select("id", ...array_keys($this->columns))
->from($this->tableName);
$query = $this->getSelectQuery();
if ($condition) {
$query->where($condition);
@@ -159,7 +280,10 @@ class DatabaseEntityHandler {
} else {
$entities = [];
foreach ($res as $row) {
$entities[] = $this->entityFromRow($row);
$entity = $this->entityFromRow($row);
if ($entity) {
$entities[$entity->getId()] = $entity;
}
}
return $entities;
}
@@ -175,7 +299,7 @@ class DatabaseEntityHandler {
$query->addColumn($column);
}
foreach ($this->relations as $constraint) {
foreach ($this->constraints as $constraint) {
$query->addConstraint($constraint);
}
@@ -187,29 +311,43 @@ class DatabaseEntityHandler {
return $query->execute();
}
public function insertOrUpdate(DatabaseEntity $entity) {
public function insertOrUpdate(DatabaseEntity $entity, ?array $columns = null) {
$id = $entity->getId();
if ($id === null) {
$columns = [];
$row = [];
$action = $id === null ? "insert" : "update";
foreach ($this->columns as $propertyName => $column) {
$columns[] = $column->getName();
$property = $this->properties[$propertyName];
if ($property->isInitialized($entity)) {
$value = $property->getValue($entity);
} else if (!$this->columns[$propertyName]->notNull()) {
$value = null;
} else {
$this->logger->error("Cannot insert entity: property '$propertyName' was not initialized yet.");
return false;
}
$row[] = $value;
$row = [];
foreach ($this->columns as $propertyName => $column) {
if ($columns && !in_array($column->getName(), $columns)) {
continue;
}
$res = $this->sql->insert($this->tableName, $columns)
->addRow(...$row)
$property = $this->properties[$propertyName];
$property->setAccessible(true);
if ($property->isInitialized($entity)) {
$value = $property->getValue($entity);
if (isset($this->relations[$propertyName])) {
$value = $value->getId();
}
} else if (!$this->columns[$propertyName]->notNull()) {
$value = null;
} else {
if ($action !== "update") {
$this->logger->error("Cannot $action entity: property '$propertyName' was not initialized yet.");
return false;
} else {
continue;
}
}
$row[$column->getName()] = $value;
}
$entity->preInsert($row);
if ($id === null) {
$res = $this->sql->insert($this->tableName, array_keys($row))
->addRow(...array_values($row))
->returning("id")
->execute();
@@ -220,20 +358,9 @@ class DatabaseEntityHandler {
}
} else {
$query = $this->sql->update($this->tableName)
->where(new Compare("id", $id));
foreach ($this->columns as $propertyName => $column) {
$columnName = $column->getName();
$property = $this->properties[$propertyName];
if ($property->isInitialized($entity)) {
$value = $property->getValue($entity);
} else if (!$this->columns[$propertyName]->notNull()) {
$value = null;
} else {
$this->logger->error("Cannot update entity: property '$propertyName' was not initialized yet.");
return false;
}
->where(new Compare($this->tableName . ".id", $id));
foreach ($row as $columnName => $value) {
$query->set($columnName, $value);
}
@@ -242,15 +369,14 @@ class DatabaseEntityHandler {
}
public function delete(int $id) {
return $this->sql->delete($this->tableName)->where(new Compare("id", $id))->execute();
return $this->sql
->delete($this->tableName)
->where(new Compare($this->tableName . ".id", $id))
->execute();
}
private function raiseError(string $message) {
$this->logger->error($message);
throw new Exception($message);
}
private function getPropertyValue() {
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Objects\DatabaseEntity;
use Driver\SQL\Condition\Condition;
use Driver\SQL\Join;
use Driver\SQL\Query\Select;
use Driver\SQL\SQL;
/**
* this class is similar to \Driver\SQL\Query\Select but with reduced functionality
* and more adapted to entities.
*/
class DatabaseEntityQuery {
private DatabaseEntityHandler $handler;
private Select $selectQuery;
private int $resultType;
private function __construct(DatabaseEntityHandler $handler, int $resultType) {
$this->handler = $handler;
$this->selectQuery = $handler->getSelectQuery();
$this->resultType = $resultType;
if ($this->resultType === SQL::FETCH_ONE) {
$this->selectQuery->first();
}
}
public static function fetchAll(DatabaseEntityHandler $handler): DatabaseEntityQuery {
return new DatabaseEntityQuery($handler, SQL::FETCH_ALL);
}
public static function fetchOne(DatabaseEntityHandler $handler): DatabaseEntityQuery {
return new DatabaseEntityQuery($handler, SQL::FETCH_ONE);
}
public function limit(int $limit): DatabaseEntityQuery {
$this->selectQuery->limit($limit);
return $this;
}
public function where(Condition ...$condition): DatabaseEntityQuery {
$this->selectQuery->where(...$condition);
return $this;
}
public function orderBy(string ...$column): DatabaseEntityQuery {
$this->selectQuery->orderBy(...$column);
return $this;
}
public function ascending(): DatabaseEntityQuery {
$this->selectQuery->ascending();
return $this;
}
public function descending(): DatabaseEntityQuery {
$this->selectQuery->descending();
return $this;
}
// TODO: clean this up
public function fetchEntities(bool $recursive = false): DatabaseEntityQuery {
// $this->selectQuery->dump();
$relIndex = 1;
foreach ($this->handler->getRelations() as $propertyName => $relationHandler) {
$this->fetchRelation($propertyName, $this->handler->getTableName(), $this->handler, $relationHandler, $relIndex, $recursive);
}
return $this;
}
private function fetchRelation(string $propertyName, string $tableName, DatabaseEntityHandler $src, DatabaseEntityHandler $relationHandler,
int &$relIndex = 1, bool $recursive = false, string $relationColumnPrefix = "") {
$columns = $src->getColumns();
$foreignColumn = $columns[$propertyName];
$foreignColumnName = $foreignColumn->getName();
$referencedTable = $relationHandler->getTableName();
$isNullable = !$foreignColumn->notNull();
$alias = "t$relIndex"; // t1, t2, t3, ...
$relIndex++;
if ($isNullable) {
$this->selectQuery->leftJoin($referencedTable, "$tableName.$foreignColumnName", "$alias.id", $alias);
} else {
$this->selectQuery->innerJoin($referencedTable, "$tableName.$foreignColumnName", "$alias.id", $alias);
}
$relationColumnPrefix .= DatabaseEntityHandler::getColumnName($propertyName) . "_";
$recursiveRelations = $relationHandler->getRelations();
foreach ($relationHandler->getColumns() as $relPropertyName => $relColumn) {
$relColumnName = $relColumn->getName();
if (!isset($recursiveRelations[$relPropertyName]) || $recursive) {
$this->selectQuery->addValue("$alias.$relColumnName as $relationColumnPrefix$relColumnName");
if (isset($recursiveRelations[$relPropertyName]) && $recursive) {
$this->fetchRelation($relPropertyName, $alias, $relationHandler, $recursiveRelations[$relPropertyName], $relIndex, $recursive, $relationColumnPrefix);
}
}
}
}
public function execute(): DatabaseEntity|array|null {
$res = $this->selectQuery->execute();
if ($res === null || $res === false) {
return null;
}
if ($this->resultType === SQL::FETCH_ALL) {
$entities = [];
foreach ($res as $row) {
$entity = $this->handler->entityFromRow($row);
if ($entity) {
$entities[$entity->getId()] = $entity;
}
}
return $entities;
} else if ($this->resultType === SQL::FETCH_ONE) {
return $this->handler->entityFromRow($res);
} else {
$this->handler->getLogger()->error("Invalid result type for query builder, must be FETCH_ALL or FETCH_ONE");
return null;
}
}
public function addJoin(Join $join): DatabaseEntityQuery {
$this->selectQuery->addJoin($join);
return $this;
}
}

View File

@@ -1,19 +1,23 @@
<?php
namespace Objects;
namespace Objects\DatabaseEntity;
class GpgKey extends ApiObject {
use Driver\SQL\Expression\CurrentTimeStamp;
use Objects\DatabaseEntity\Attribute\MaxLength;
use Objects\DatabaseEntity\Attribute\DefaultValue;
class GpgKey extends DatabaseEntity {
const GPG2 = "/usr/bin/gpg2";
private int $id;
private bool $confirmed;
private string $fingerprint;
private string $algorithm;
#[MaxLength(64)] private string $fingerprint;
#[MaxLength(64)] private string $algorithm;
private \DateTime $expires;
#[DefaultValue(CurrentTimeStamp::class)] private \DateTime $added;
public function __construct(int $id, bool $confirmed, string $fingerprint, string $algorithm, string $expires) {
$this->id = $id;
parent::__construct($id);
$this->confirmed = $confirmed;
$this->fingerprint = $fingerprint;
$this->algorithm = $algorithm;
@@ -25,26 +29,17 @@ class GpgKey extends ApiObject {
$cmd = self::GPG2 . " --encrypt --output - --recipient $gpgFingerprint --trust-model always --batch --armor";
list($out, $err) = self::proc_exec($cmd, $body, true);
if ($out === null) {
return self::createError("Error while communicating with GPG agent");
return createError("Error while communicating with GPG agent");
} else if ($err) {
return self::createError($err);
return createError($err);
} else {
return ["success" => true, "data" => $out];
}
}
public function jsonSerialize(): array {
return array(
"fingerprint" => $this->fingerprint,
"algorithm" => $this->algorithm,
"expires" => $this->expires->getTimestamp(),
"confirmed" => $this->confirmed
);
}
private static function proc_exec(string $cmd, ?string $stdin = null, bool $raw = false): ?array {
$descriptorSpec = array(0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]);
$process = proc_open($cmd, $descriptorSpec,$pipes);
$process = proc_open($cmd, $descriptorSpec, $pipes);
if (!is_resource($process)) {
return null;
}
@@ -62,29 +57,25 @@ class GpgKey extends ApiObject {
return [($raw ? $out : trim($out)), $err];
}
private static function createError(string $error) : array {
return ["success" => false, "error" => $error];
}
public static function getKeyInfo(string $key): array {
list($out, $err) = self::proc_exec(self::GPG2 . " --show-key", $key);
if ($out === null) {
return self::createError("Error while communicating with GPG agent");
return createError("Error while communicating with GPG agent");
}
if ($err) {
return self::createError($err);
return createError($err);
}
$lines = explode("\n", $out);
if (count($lines) > 4) {
return self::createError("It seems like you have uploaded more than one GPG-Key");
return createError("It seems like you have uploaded more than one GPG-Key");
} else if (count($lines) !== 4 || !preg_match("/(\S+)\s+(\w+)\s+.*\[expires: ([0-9-]+)]/", $lines[0], $matches)) {
return self::createError("Error parsing GPG output");
return createError("Error parsing GPG output");
}
$keyType = $matches[1];
$keyAlg = $matches[2];
$keyAlg = $matches[2];
$expires = \DateTime::createFromFormat("Y-m-d", $matches[3]);
$fingerprint = trim($lines[1]);
$keyData = ["type" => $keyType, "algorithm" => $keyAlg, "expires" => $expires, "fingerprint" => $fingerprint];
@@ -94,17 +85,17 @@ class GpgKey extends ApiObject {
public static function importKey(string $key): array {
list($out, $err) = self::proc_exec(self::GPG2 . " --import", $key);
if ($out === null) {
return self::createError("Error while communicating with GPG agent");
return createError("Error while communicating with GPG agent");
}
if (preg_match("/gpg:\s+Total number processed:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0) {
if ((preg_match("/.*\s+unchanged:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0) ||
(preg_match("/.*\s+imported:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0)) {
(preg_match("/.*\s+imported:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0)) {
return ["success" => true];
}
}
return self::createError($err);
return createError($err);
}
public static function export($gpgFingerprint, bool $armored): array {
@@ -115,7 +106,7 @@ class GpgKey extends ApiObject {
$cmd .= escapeshellarg($gpgFingerprint);
list($out, $err) = self::proc_exec($cmd);
if ($err) {
return self::createError($err);
return createError($err);
}
return ["success" => true, "data" => $out];
@@ -125,12 +116,18 @@ class GpgKey extends ApiObject {
return $this->confirmed;
}
public function getId(): int {
return $this->id;
}
public function getFingerprint(): string {
return $this->fingerprint;
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"fingerprint" => $this->fingerprint,
"algorithm" => $this->algorithm,
"expires" => $this->expires->getTimestamp(),
"added" => $this->added->getTimestamp(),
"confirmed" => $this->confirmed
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Objects\DatabaseEntity;
use Objects\DatabaseEntity\Attribute\MaxLength;
class Group extends DatabaseEntity {
#[MaxLength(32)] public string $name;
#[MaxLength(10)] public string $color;
public function __construct(?int $id = null) {
parent::__construct($id);
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"name" => $this->name,
"color" => $this->color
];
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Objects\DatabaseEntity {
use Objects\DatabaseEntity\Attribute\MaxLength;
use Objects\DatabaseEntity\Attribute\Transient;
use Objects\lang\LanguageModule;
// TODO: language from cookie?
class Language extends DatabaseEntity {
const LANG_CODE_PATTERN = "/^[a-zA-Z]{2}_[a-zA-Z]{2}$/";
#[MaxLength(5)] private string $code;
#[MaxLength(32)] private string $name;
#[Transient] private array $modules;
#[Transient] protected array $entries;
public function __construct(int $id, string $code, string $name) {
parent::__construct($id);
$this->code = $code;
$this->name = $name;
$this->entries = array();
$this->modules = array();
}
public function getCode(): string {
return $this->code;
}
public function getShortCode(): string {
return substr($this->code, 0, 2);
}
public function getName(): string {
return $this->name;
}
public function loadModule(LanguageModule|string $module) {
if (!is_object($module)) {
$module = new $module();
}
$moduleEntries = $module->getEntries($this->code);
$this->entries = array_merge($this->entries, $moduleEntries);
$this->modules[] = $module;
}
public function translate(string $key): string {
return $this->entries[$key] ?? $key;
}
public function sendCookie(string $domain) {
setcookie('lang', $this->code, 0, "/", $domain, false, false);
}
public function jsonSerialize(): array {
return array(
'id' => $this->getId(),
'code' => $this->code,
'shortCode' => explode("_", $this->code)[0],
'name' => $this->name,
);
}
public function activate() {
global $LANGUAGE;
$LANGUAGE = $this;
}
public static function DEFAULT_LANGUAGE(bool $fromCookie = true): Language {
if ($fromCookie && isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'];
$acceptedLanguages = explode(',', $acceptLanguage);
foreach ($acceptedLanguages as $code) {
if (strlen($code) == 2) {
$code = $code . '_' . strtoupper($code);
}
$code = str_replace("-", "_", $code);
if (!preg_match(self::LANG_CODE_PATTERN, $code)) {
continue;
}
return new Language(0, $code, "");
}
}
return new Language(1, "en_US", "American English");
}
}
}
namespace {
function L($key) {
if (!array_key_exists('LANGUAGE', $GLOBALS))
return $key;
global $LANGUAGE;
return $LANGUAGE->translate($key);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Objects\DatabaseEntity;
use Api\Parameter\Parameter;
use Driver\SQL\Expression\CurrentTimeStamp;
use Objects\DatabaseEntity\Attribute\DefaultValue;
use Objects\DatabaseEntity\Attribute\MaxLength;
class News extends DatabaseEntity {
public User $publishedBy;
#[DefaultValue(CurrentTimeStamp::class)] private \DateTime $publishedAt;
#[MaxLength(128)] public string $title;
#[MaxLength(1024)] public string $text;
public function __construct(?int $id = null) {
parent::__construct($id);
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"publishedBy" => $this->publishedBy->jsonSerialize(),
"publishedAt" => $this->publishedAt->format(Parameter::DATE_TIME_FORMAT),
"title" => $this->title,
"text" => $this->text
];
}
}

View File

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

View File

@@ -0,0 +1,134 @@
<?php
namespace Objects\DatabaseEntity;
use DateTime;
use Exception;
use Firebase\JWT\JWT;
use Objects\Context;
use Objects\DatabaseEntity\Attribute\DefaultValue;
use Objects\DatabaseEntity\Attribute\Json;
use Objects\DatabaseEntity\Attribute\MaxLength;
use Objects\DatabaseEntity\Attribute\Transient;
class Session extends DatabaseEntity {
# in minutes
const DURATION = 60 * 60 * 24 * 14;
#[Transient] private Context $context;
private User $user;
private DateTime $expires;
#[MaxLength(45)] private string $ipAddress;
#[DefaultValue(true)] private bool $active;
#[MaxLength(64)] private ?string $os;
#[MaxLength(64)] private ?string $browser;
#[DefaultValue(true)] public bool $stayLoggedIn;
#[MaxLength(16)] private string $csrfToken;
#[Json] private mixed $data;
public function __construct(Context $context, User $user, ?string $csrfToken = null) {
parent::__construct();
$this->context = $context;
$this->user = $user;
$this->stayLoggedIn = false;
$this->csrfToken = $csrfToken ?? generateRandomString(16);
$this->expires = (new DateTime())->modify(sprintf("+%d second", Session::DURATION));
$this->active = true;
}
public static function init(Context $context, int $userId, int $sessionId): ?Session {
$session = Session::find($context->getSQL(), $sessionId, true, true);
if (!$session || !$session->active || $session->user->getId() !== $userId) {
return null;
}
$session->context = $context;
return $session;
}
public function getUser(): User {
return $this->user;
}
private function updateMetaData() {
$this->expires = (new \DateTime())->modify(sprintf("+%d minutes", Session::DURATION));
$this->ipAddress = $this->context->isCLI() ? "127.0.0.1" : $_SERVER['REMOTE_ADDR'];
try {
$userAgent = @get_browser($_SERVER['HTTP_USER_AGENT'], true);
$this->os = $userAgent['platform'] ?? "Unknown";
$this->browser = $userAgent['parent'] ?? "Unknown";
} catch (Exception $ex) {
$this->os = "Unknown";
$this->browser = "Unknown";
}
}
public function setData(array $data) {
foreach ($data as $key => $value) {
$_SESSION[$key] = $value;
}
}
public function getCookie(): string {
$this->updateMetaData();
$settings = $this->context->getSettings();
$token = ['userId' => $this->user->getId(), 'sessionId' => $this->getId()];
$jwtKey = $settings->getJwtKey();
return JWT::encode($token, $jwtKey->getKeyMaterial(), $jwtKey->getAlgorithm());
}
public function sendCookie(string $domain) {
$sessionCookie = $this->getCookie();
$secure = strcmp(getProtocol(), "https") === 0;
setcookie('session', $sessionCookie, $this->getExpiresTime(), "/", $domain, $secure, true);
}
public function getExpiresTime(): int {
return ($this->stayLoggedIn ? $this->expires->getTimestamp() : 0);
}
public function getExpiresSeconds(): int {
return ($this->stayLoggedIn ? $this->expires->getTimestamp() - time() : -1);
}
public function jsonSerialize(): array {
return array(
'id' => $this->getId(),
'active' => $this->active,
'expires' => $this->expires,
'ipAddress' => $this->ipAddress,
'os' => $this->os,
'browser' => $this->browser,
'csrf_token' => $this->csrfToken,
'data' => $this->data,
);
}
public function insert(bool $stayLoggedIn = false): bool {
$this->stayLoggedIn = $stayLoggedIn;
$this->active = true;
return $this->update();
}
public function destroy(): bool {
session_destroy();
$this->active = false;
return $this->save($this->context->getSQL());
}
public function update(): bool {
$this->updateMetaData();
$this->expires = (new DateTime())->modify(sprintf("+%d second", Session::DURATION));
$this->data = json_encode($_SESSION ?? []);
$sql = $this->context->getSQL();
return $this->user->update($sql) &&
$this->save($sql);
}
public function getCsrfToken(): string {
return $this->csrfToken;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Objects\DatabaseEntity;
use Api\Parameter\Parameter;
use Driver\SQL\Expression\CurrentTimeStamp;
use Objects\DatabaseEntity\Attribute\DefaultValue;
use Objects\DatabaseEntity\Attribute\Enum;
use Objects\DatabaseEntity\Attribute\MaxLength;
class SystemLog extends DatabaseEntity {
#[DefaultValue(CurrentTimeStamp::class)] private \DateTime $timestamp;
private string $message;
#[MaxLength(64)] #[DefaultValue('global')] private string $module;
#[Enum('debug','info','warning','error','severe')] private string $severity;
public function __construct(?int $id = null) {
parent::__construct($id);
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"timestamp" => $this->timestamp->format(Parameter::DATE_TIME_FORMAT),
"message" => $this->message,
"module" => $this->module,
"severity" => $this->severity
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Objects\DatabaseEntity;
use Driver\SQL\SQL;
use Objects\DatabaseEntity\Attribute\Enum;
use Objects\DatabaseEntity\Attribute\MaxLength;
use Objects\TwoFactor\KeyBasedTwoFactorToken;
use Objects\TwoFactor\TimeBasedTwoFactorToken;
abstract class TwoFactorToken extends DatabaseEntity {
#[Enum('totp','fido')] private string $type;
private bool $confirmed;
private bool $authenticated;
#[MaxLength(512)] private string $data;
public function __construct(string $type, ?int $id = null, bool $confirmed = false) {
parent::__construct($id);
$this->id = $id;
$this->type = $type;
$this->confirmed = $confirmed;
$this->authenticated = $_SESSION["2faAuthenticated"] ?? false;
}
public function jsonSerialize(): array {
return [
"id" => $this->getId(),
"type" => $this->type,
"confirmed" => $this->confirmed,
"authenticated" => $this->authenticated,
];
}
public abstract function getData(): string;
protected abstract function readData(string $data);
public function preInsert(array &$row) {
$row["data"] = $this->getData();
}
public function postFetch(SQL $sql, array $row) {
parent::postFetch($sql, $row);
$this->readData($row["data"]);
}
public function authenticate() {
$this->authenticated = true;
$_SESSION["2faAuthenticated"] = true;
}
public function getType(): string {
return $this->type;
}
public function isConfirmed(): bool {
return $this->confirmed;
}
public function getId(): int {
return $this->id;
}
public static function newInstance(\ReflectionClass $reflectionClass, array $row) {
if ($row["type"] === TimeBasedTwoFactorToken::TYPE) {
return (new \ReflectionClass(TimeBasedTwoFactorToken::class))->newInstanceWithoutConstructor();
} else if ($row["type"] === KeyBasedTwoFactorToken::TYPE) {
return (new \ReflectionClass(KeyBasedTwoFactorToken::class))->newInstanceWithoutConstructor();
} else {
// TODO: error message
return null;
}
}
public function isAuthenticated(): bool {
return $this->authenticated;
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace Objects\DatabaseEntity;
use Driver\SQL\Condition\Compare;
use Driver\SQL\Expression\CurrentTimeStamp;
use Driver\SQL\Join;
use Driver\SQL\SQL;
use Objects\DatabaseEntity\Attribute\DefaultValue;
use Objects\DatabaseEntity\Attribute\MaxLength;
use Objects\DatabaseEntity\Attribute\Transient;
use Objects\DatabaseEntity\Attribute\Unique;
class User extends DatabaseEntity {
#[MaxLength(32)] #[Unique] public string $name;
#[MaxLength(128)] public string $password;
#[MaxLength(64)] public string $fullName;
#[MaxLength(64)] #[Unique] public ?string $email;
#[MaxLength(64)] private ?string $profilePicture;
private ?\DateTime $lastOnline;
#[DefaultValue(CurrentTimeStamp::class)] public \DateTime $registeredAt;
public bool $confirmed;
#[DefaultValue(1)] public Language $language;
private ?GpgKey $gpgKey;
private ?TwoFactorToken $twoFactorToken;
#[Transient] private array $groups;
public function __construct(?int $id = null) {
parent::__construct($id);
$this->groups = [];
}
public function postFetch(SQL $sql, array $row) {
parent::postFetch($sql, $row);
$this->groups = [];
$groups = Group::findAllBuilder($sql)
->fetchEntities()
->addJoin(new Join("INNER", "UserGroup", "UserGroup.group_id", "Group.id"))
->where(new Compare("UserGroup.user_id", $this->id))
->execute();
if ($groups) {
$this->groups = $groups;
}
}
public function getUsername(): string {
return $this->name;
}
public function getFullName(): string {
return $this->fullName;
}
public function getEmail(): ?string {
return $this->email;
}
public function getGroups(): array {
return $this->groups;
}
public function hasGroup(int $group): bool {
return isset($this->groups[$group]);
}
public function getGPG(): ?GpgKey {
return $this->gpgKey;
}
public function getTwoFactorToken(): ?TwoFactorToken {
return $this->twoFactorToken;
}
public function getProfilePicture(): ?string {
return $this->profilePicture;
}
public function __debugInfo(): array {
return [
'id' => $this->getId(),
'username' => $this->name,
'language' => $this->language->getName(),
];
}
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'name' => $this->name,
'fullName' => $this->fullName,
'profilePicture' => $this->profilePicture,
'email' => $this->email,
'groups' => $this->groups ?? null,
'language' => (isset($this->language) ? $this->language->jsonSerialize() : null),
'session' => (isset($this->session) ? $this->session->jsonSerialize() : null),
"gpg" => (isset($this->gpgKey) ? $this->gpgKey->jsonSerialize() : null),
"2fa" => (isset($this->twoFactorToken) ? $this->twoFactorToken->jsonSerialize() : null),
];
}
public function update(SQL $sql): bool {
$this->lastOnline = new \DateTime();
return $this->save($sql, ["last_online", "language_id"]);
}
}

View File

@@ -1,139 +0,0 @@
<?php
namespace Objects {
use Objects\lang\LanguageModule;
class Language extends ApiObject {
const LANG_CODE_PATTERN = "/^[a-zA-Z]+_[a-zA-Z]+$/";
private int $languageId;
private string $langCode;
private string $langName;
private array $modules;
protected array $entries;
public function __construct($languageId, $langCode, $langName) {
$this->languageId = $languageId;
$this->langCode = $langCode;
$this->langName = $langName;
$this->entries = array();
$this->modules = array();
}
public function getId() { return $this->languageId; }
public function getCode(): string { return $this->langCode; }
public function getShortCode() { return substr($this->langCode, 0, 2); }
public function getName() { return $this->langName; }
/**
* @param $module LanguageModule class or object
*/
public function loadModule($module) {
if(!is_object($module))
$module = new $module;
$aModuleEntries = $module->getEntries($this->langCode);
$this->entries = array_merge($this->entries, $aModuleEntries);
$this->modules[] = $module;
}
public function translate(string $key): string {
if(isset($this->entries[$key]))
return $this->entries[$key];
return $key;
}
public function sendCookie(?string $domain = null) {
$domain = empty($domain) ? "" : $domain;
setcookie('lang', $this->langCode, 0, "/", $domain, false, false);
}
public function jsonSerialize(): array {
return array(
'uid' => $this->languageId,
'code' => $this->langCode,
'shortCode' => explode("_", $this->langCode)[0],
'name' => $this->langName,
);
}
public static function newInstance($languageId, $langCode, $langName) {
if(!preg_match(Language::LANG_CODE_PATTERN, $langCode)) {
return false;
}
// TODO: include dynamically wanted Language
return new Language($languageId, $langCode, $langName);
// $className = $langCode
// return new $className($languageId, $langCode);
}
public function load() {
global $LANGUAGE;
$LANGUAGE = $this;
}
public static function DEFAULT_LANGUAGE() {
if(isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$acceptLanguage = $_SERVER['HTTP_ACCEPT_LANGUAGE'];
$aSplit = explode(',',$acceptLanguage);
foreach($aSplit as $code) {
if(strlen($code) == 2) {
$code = $code . '_' . strtoupper($code);
}
$code = str_replace("-", "_", $code);
if(strlen($code) != 5)
continue;
$lang = Language::newInstance(0, $code, "");
if($lang)
return $lang;
}
}
return Language::newInstance(1, "en_US", "American English");
}
};
}
namespace {
function L($key) {
if(!array_key_exists('LANGUAGE', $GLOBALS))
return $key;
global $LANGUAGE;
return $LANGUAGE->translate($key);
}
function LANG_NAME() {
if(!array_key_exists('LANGUAGE', $GLOBALS))
return "LANG_NAME";
global $LANGUAGE;
return $LANGUAGE->getName();
}
function LANG_CODE() {
if(!array_key_exists('LANGUAGE', $GLOBALS))
return "LANG_CODE";
global $LANGUAGE;
return $LANGUAGE->getCode();
}
function SHORT_LANG_CODE() {
if(!array_key_exists('LANGUAGE', $GLOBALS))
return "SHORT_LANG_CODE";
global $LANGUAGE;
return $LANGUAGE->getShortCode();
}
}

View File

@@ -13,7 +13,6 @@ class ApiRoute extends AbstractRoute {
}
public function call(Router $router, array $params): string {
$user = $router->getUser();
if (empty($params["endpoint"])) {
header("Content-Type: text/html");
$document = new \Elements\TemplateDocument($router, "swagger.twig");
@@ -43,9 +42,11 @@ class ApiRoute extends AbstractRoute {
http_response_code(400);
$response = createError("Invalid Method");
} else {
$request = $apiClass->newInstanceArgs(array($user, true));
$request->execute();
$response = $request->getJsonResult();
$request = $apiClass->newInstanceArgs(array($router->getContext(), true));
$success = $request->execute();
$response = $request->getResult();
$response["success"] = $success;
$response["msg"] = $request->getLastError();
}
}
} catch (ReflectionException $e) {
@@ -55,6 +56,6 @@ class ApiRoute extends AbstractRoute {
}
header("Content-Type: application/json");
return $response;
return json_encode($response);
}
}

View File

@@ -3,23 +3,24 @@
namespace Objects\Router;
use Driver\Logger\Logger;
use Objects\User;
use Objects\Context;
class Router {
private ?User $user;
private Context $context;
private Logger $logger;
protected array $routes;
protected array $statusCodeRoutes;
public function __construct(?User $user = null) {
$this->user = $user;
public function __construct(Context $context) {
$this->context = $context;
$this->routes = [];
$this->statusCodeRoutes = [];
if ($user) {
$sql = $context->getSQL();
if ($sql) {
$this->addRoute(new ApiRoute());
$this->logger = new Logger("Router", $user->getSQL());
$this->logger = new Logger("Router", $sql);
} else {
$this->logger = new Logger("Router");
}
@@ -48,7 +49,7 @@ class Router {
if ($route) {
return $route->call($this, $params);
} else {
$req = new \Api\Template\Render($this->user);
$req = new \Api\Template\Render($this->context);
$res = $req->execute(["file" => "error_document.twig", "parameters" => $params]);
if ($res) {
return $req->getResult()["html"];
@@ -90,13 +91,13 @@ class Router {
*/
namespace Cache;
use Objects\User;
use Objects\Context;
use Objects\Router\Router;
class RouterCache extends Router {
public function __construct(User \$user) {
parent::__construct(\$user);$routes
public function __construct(Context \$context) {
parent::__construct(\$context);$routes
}
}
";
@@ -109,8 +110,8 @@ class RouterCache extends Router {
return true;
}
public function getUser(): User {
return $this->user;
public function getContext(): Context {
return $this->context;
}
public function getLogger(): Logger {

View File

@@ -37,9 +37,9 @@ class StaticFileRoute extends AbstractRoute {
}
$pathInfo = pathinfo($path);
if ($router !== null && ($user = $router->getUser()) !== null) {
if ($router !== null) {
$ext = $pathInfo["extension"] ?? "";
if (!$user->getConfiguration()->getSettings()->isExtensionAllowed($ext)) {
if (!$router->getContext()->getSettings()->isExtensionAllowed($ext)) {
http_response_code(406);
echo "<b>Access restricted:</b> Extension '" . htmlspecialchars($ext) . "' not allowed to serve.";
}

View File

@@ -1,164 +0,0 @@
<?php
namespace Objects;
use DateTime;
use \Driver\SQL\Condition\Compare;
use Driver\SQL\Expression\CurrentTimeStamp;
use Exception;
use Firebase\JWT\JWT;
class Session extends ApiObject {
# in minutes
const DURATION = 60*60*24*14;
private ?int $sessionId;
private User $user;
private int $expires;
private string $ipAddress;
private ?string $os;
private ?string $browser;
private bool $stayLoggedIn;
private string $csrfToken;
public function __construct(User $user, ?int $sessionId, ?string $csrfToken) {
$this->user = $user;
$this->sessionId = $sessionId;
$this->stayLoggedIn = false;
$this->csrfToken = $csrfToken ?? generateRandomString(16);
}
public static function create(User $user, bool $stayLoggedIn = false): ?Session {
$session = new Session($user, null, null);
if ($session->insert($stayLoggedIn)) {
$session->stayLoggedIn = $stayLoggedIn;
return $session;
}
return null;
}
private function updateMetaData() {
$this->expires = time() + Session::DURATION;
$this->ipAddress = is_cli() ? "127.0.0.1" : $_SERVER['REMOTE_ADDR'];
try {
$userAgent = @get_browser($_SERVER['HTTP_USER_AGENT'], true);
$this->os = $userAgent['platform'] ?? "Unknown";
$this->browser = $userAgent['parent'] ?? "Unknown";
} catch(Exception $ex) {
$this->os = "Unknown";
$this->browser = "Unknown";
}
}
public function setData(array $data) {
foreach($data as $key => $value) {
$_SESSION[$key] = $value;
}
}
public function stayLoggedIn(bool $val) {
$this->stayLoggedIn = $val;
}
public function getCookie(): string {
$this->updateMetaData();
$settings = $this->user->getConfiguration()->getSettings();
$token = ['userId' => $this->user->getId(), 'sessionId' => $this->sessionId];
$jwtKey = $settings->getJwtKey();
return JWT::encode($token, $jwtKey->getKeyMaterial(), $jwtKey->getAlgorithm());
}
public function sendCookie(?string $domain = null) {
$domain = empty($domain) ? "" : $domain;
$sessionCookie = $this->getCookie();
$secure = strcmp(getProtocol(), "https") === 0;
setcookie('session', $sessionCookie, $this->getExpiresTime(), "/", $domain, $secure, true);
}
public function getExpiresTime(): int {
return ($this->stayLoggedIn ? $this->expires : 0);
}
public function getExpiresSeconds(): int {
return ($this->stayLoggedIn ? $this->expires - time() : -1);
}
public function jsonSerialize(): array {
return array(
'uid' => $this->sessionId,
'user_id' => $this->user->getId(),
'expires' => $this->expires,
'ipAddress' => $this->ipAddress,
'os' => $this->os,
'browser' => $this->browser,
'csrf_token' => $this->csrfToken
);
}
public function insert(bool $stayLoggedIn = false): bool {
$this->updateMetaData();
$sql = $this->user->getSQL();
$minutes = Session::DURATION;
$data = [
"expires" => (new DateTime())->modify("+$minutes minute"),
"user_id" => $this->user->getId(),
"ipAddress" => $this->ipAddress,
"os" => $this->os,
"browser" => $this->browser,
"data" => json_encode($_SESSION ?? []),
"stay_logged_in" => $stayLoggedIn,
"csrf_token" => $this->csrfToken
];
$success = $sql
->insert("Session", array_keys($data))
->addRow(...array_values($data))
->returning("uid")
->execute();
if ($success) {
$this->sessionId = $this->user->getSQL()->getLastInsertId();
return true;
}
return false;
}
public function destroy(): bool {
session_destroy();
return $this->user->getSQL()->update("Session")
->set("active", false)
->where(new Compare("Session.uid", $this->sessionId))
->where(new Compare("Session.user_id", $this->user->getId()))
->execute();
}
public function update(): bool {
$this->updateMetaData();
$minutes = Session::DURATION;
$sql = $this->user->getSQL();
return
$sql->update("User")
->set("last_online", new CurrentTimeStamp())
->where(new Compare("uid", $this->user->getId()))
->execute() &&
$sql->update("Session")
->set("Session.expires", (new DateTime())->modify("+$minutes second"))
->set("Session.ipAddress", $this->ipAddress)
->set("Session.os", $this->os)
->set("Session.browser", $this->browser)
->set("Session.data", json_encode($_SESSION ?? []))
->set("Session.csrf_token", $this->csrfToken)
->where(new Compare("Session.uid", $this->sessionId))
->where(new Compare("Session.user_id", $this->user->getId()))
->execute();
}
public function getCsrfToken(): string {
return $this->csrfToken;
}
}

View File

@@ -3,18 +3,19 @@
namespace Objects\TwoFactor;
use Cose\Algorithm\Signature\ECDSA\ECSignature;
use Objects\DatabaseEntity\Attribute\Transient;
use Objects\DatabaseEntity\TwoFactorToken;
class KeyBasedTwoFactorToken extends TwoFactorToken {
const TYPE = "fido";
private ?string $challenge;
private ?string $credentialId;
private ?PublicKey $publicKey;
#[Transient] private ?string $challenge;
#[Transient] private ?string $credentialId;
#[Transient] private ?PublicKey $publicKey;
public function __construct(string $data, ?int $id = null, bool $confirmed = false) {
parent::__construct(self::TYPE, $id, $confirmed);
if (!$confirmed) {
protected function readData(string $data) {
if ($this->isConfirmed()) {
$this->challenge = base64_decode($data);
$this->credentialId = null;
$this->publicKey = null;
@@ -34,7 +35,7 @@ class KeyBasedTwoFactorToken extends TwoFactorToken {
return $this->publicKey;
}
public function getCredentialId() {
public function getCredentialId(): ?string {
return $this->credentialId;
}

View File

@@ -5,22 +5,29 @@ namespace Objects\TwoFactor;
use Base32\Base32;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Objects\User;
use Objects\Context;
use Objects\DatabaseEntity\Attribute\Transient;
use Objects\DatabaseEntity\TwoFactorToken;
use Objects\DatabaseEntity\User;
class TimeBasedTwoFactorToken extends TwoFactorToken {
const TYPE = "totp";
private string $secret;
#[Transient] private string $secret;
public function __construct(string $secret, ?int $id = null, bool $confirmed = false) {
parent::__construct(self::TYPE, $id, $confirmed);
public function __construct(string $secret) {
parent::__construct(self::TYPE);
$this->secret = $secret;
}
public function getUrl(User $user): string {
protected function readData(string $data) {
$this->secret = $data;
}
public function getUrl(Context $context): string {
$otpType = self::TYPE;
$name = rawurlencode($user->getUsername());
$settings = $user->getConfiguration()->getSettings();
$name = rawurlencode($context->getUser()->getUsername());
$settings = $context->getSettings();
$urlArgs = [
"secret" => $this->secret,
"issuer" => $settings->getSiteName(),
@@ -30,10 +37,10 @@ class TimeBasedTwoFactorToken extends TwoFactorToken {
return "otpauth://$otpType/$name?$urlArgs";
}
public function generateQRCode(User $user) {
public function generateQRCode(Context $context) {
$options = new QROptions(['outputType' => QRCode::OUTPUT_IMAGE_PNG, "imageBase64" => false]);
$qrcode = new QRCode($options);
return $qrcode->render($this->getUrl($user));
return $qrcode->render($this->getUrl($context));
}
public function generate(?int $at = null, int $length = 6, int $period = 30): string {

View File

@@ -1,62 +0,0 @@
<?php
namespace Objects\TwoFactor;
use Objects\ApiObject;
abstract class TwoFactorToken extends ApiObject {
private ?int $id;
private string $type;
private bool $confirmed;
private bool $authenticated;
public function __construct(string $type, ?int $id = null, bool $confirmed = false) {
$this->id = $id;
$this->type = $type;
$this->confirmed = $confirmed;
$this->authenticated = $_SESSION["2faAuthenticated"] ?? false;
}
public function jsonSerialize(): array {
return [
"id" => $this->id,
"type" => $this->type,
"confirmed" => $this->confirmed,
"authenticated" => $this->authenticated,
];
}
public abstract function getData(): string;
public function authenticate() {
$this->authenticated = true;
$_SESSION["2faAuthenticated"] = true;
}
public function getType(): string {
return $this->type;
}
public function isConfirmed(): bool {
return $this->confirmed;
}
public function getId(): int {
return $this->id;
}
public static function newInstance(string $type, string $data, ?int $id = null, bool $confirmed = false) {
if ($type === TimeBasedTwoFactorToken::TYPE) {
return new TimeBasedTwoFactorToken($data, $id, $confirmed);
} else if ($type === KeyBasedTwoFactorToken::TYPE) {
return new KeyBasedTwoFactorToken($data, $id, $confirmed);
} else {
// TODO: error message
return null;
}
}
public function isAuthenticated(): bool {
return $this->authenticated;
}
}

View File

@@ -1,376 +0,0 @@
<?php
namespace Objects;
use Configuration\Configuration;
use Driver\SQL\Condition\CondAnd;
use Exception;
use Driver\SQL\SQL;
use Driver\SQL\Condition\Compare;
use Firebase\JWT\JWT;
use Objects\TwoFactor\TwoFactorToken;
class User extends ApiObject {
private ?SQL $sql;
private Configuration $configuration;
private bool $loggedIn;
private ?Session $session;
private int $uid;
private string $username;
private string $fullName;
private ?string $email;
private ?string $profilePicture;
private Language $language;
private array $groups;
private ?GpgKey $gpgKey;
private ?TwoFactorToken $twoFactorToken;
public function __construct($configuration) {
$this->configuration = $configuration;
$this->reset();
$this->connectDB();
if (!is_cli()) {
@session_start();
$this->setLanguage(Language::DEFAULT_LANGUAGE());
$this->parseCookies();
}
}
public function __destruct() {
if ($this->sql && $this->sql->isConnected()) {
$this->sql->close();
}
}
public function connectDB(): bool {
$databaseConf = $this->configuration->getDatabase();
if ($databaseConf) {
$this->sql = SQL::createConnection($databaseConf);
if ($this->sql->isConnected()) {
$settings = $this->configuration->getSettings();
$settings->loadFromDatabase($this);
return true;
}
} else {
$this->sql = null;
}
return false;
}
public function getId(): int {
return $this->uid;
}
public function isLoggedIn(): bool {
return $this->loggedIn;
}
public function getUsername(): string {
return $this->username;
}
public function getFullName(): string {
return $this->fullName;
}
public function getEmail(): ?string {
return $this->email;
}
public function getSQL(): ?SQL {
return $this->sql;
}
public function getLanguage(): Language {
return $this->language;
}
public function setLanguage(Language $language) {
$this->language = $language;
$language->load();
}
public function getSession(): ?Session {
return $this->session;
}
public function getConfiguration(): Configuration {
return $this->configuration;
}
public function getGroups(): array {
return $this->groups;
}
public function hasGroup(int $group): bool {
return isset($this->groups[$group]);
}
public function getGPG(): ?GpgKey {
return $this->gpgKey;
}
public function getTwoFactorToken(): ?TwoFactorToken {
return $this->twoFactorToken;
}
public function getProfilePicture(): ?string {
return $this->profilePicture;
}
public function __debugInfo(): array {
$debugInfo = array(
'loggedIn' => $this->loggedIn,
'language' => $this->language->getName(),
);
if ($this->loggedIn) {
$debugInfo['uid'] = $this->uid;
$debugInfo['username'] = $this->username;
}
return $debugInfo;
}
public function jsonSerialize(): array {
if ($this->isLoggedIn()) {
return array(
'uid' => $this->uid,
'name' => $this->username,
'fullName' => $this->fullName,
'profilePicture' => $this->profilePicture,
'email' => $this->email,
'groups' => $this->groups,
'language' => $this->language->jsonSerialize(),
'session' => $this->session->jsonSerialize(),
"gpg" => ($this->gpgKey ? $this->gpgKey->jsonSerialize() : null),
"2fa" => ($this->twoFactorToken ? $this->twoFactorToken->jsonSerialize() : null),
);
} else {
return array(
'language' => $this->language->jsonSerialize(),
);
}
}
private function reset() {
$this->uid = 0;
$this->username = '';
$this->email = '';
$this->groups = [];
$this->loggedIn = false;
$this->session = null;
$this->profilePicture = null;
$this->gpgKey = null;
$this->twoFactorToken = null;
}
public function logout(): bool {
$success = true;
if ($this->loggedIn) {
$success = $this->session->destroy();
$this->reset();
}
return $success;
}
public function updateLanguage($lang): bool {
if ($this->sql) {
$request = new \Api\Language\Set($this);
return $request->execute(array("langCode" => $lang));
} else {
return false;
}
}
public function sendCookies() {
$baseUrl = $this->getConfiguration()->getSettings()->getBaseUrl();
$domain = parse_url($baseUrl, PHP_URL_HOST);
if ($this->loggedIn) {
$this->session->sendCookie($domain);
}
$this->language->sendCookie($domain);
session_write_close();
}
/**
* @param $userId user's id
* @param $sessionId session's id
* @param bool $sessionUpdate update session information, including session's lifetime and browser information
* @return bool true, if the data could be loaded
*/
public function loadSession($userId, $sessionId, bool $sessionUpdate = true): bool {
$userRow = $this->loadUser("Session", ["Session.data", "Session.stay_logged_in", "Session.csrf_token"], [
new Compare("User.uid", $userId),
new Compare("Session.uid", $sessionId),
new Compare("Session.active", true),
]);
if ($userRow !== false) {
$this->session = new Session($this, $sessionId, $userRow["csrf_token"]);
$this->session->setData(json_decode($userRow["data"] ?? '{}', true));
$this->session->stayLoggedIn($this->sql->parseBool($userRow["stay_logged_in"]));
if ($sessionUpdate) {
$this->session->update();
}
$this->loggedIn = true;
}
return $userRow !== false;
}
private function parseCookies() {
if (isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
try {
$token = $_COOKIE['session'];
$settings = $this->configuration->getSettings();
$decoded = (array)JWT::decode($token, $settings->getJwtKey());
if (!is_null($decoded)) {
$userId = ($decoded['userId'] ?? NULL);
$sessionId = ($decoded['sessionId'] ?? NULL);
if (!is_null($userId) && !is_null($sessionId)) {
$this->loadSession($userId, $sessionId);
}
}
} catch (Exception $e) {
// ignored
}
}
if (isset($_GET['lang']) && is_string($_GET["lang"]) && !empty($_GET["lang"])) {
$this->updateLanguage($_GET['lang']);
} else if (isset($_COOKIE['lang']) && is_string($_COOKIE["lang"]) && !empty($_COOKIE["lang"])) {
$this->updateLanguage($_COOKIE['lang']);
}
}
public function createSession(int $userId, bool $stayLoggedIn = false): bool {
$this->uid = $userId;
$this->session = Session::create($this, $stayLoggedIn);
if ($this->session) {
$this->loggedIn = true;
return true;
}
return false;
}
private function loadUser(string $table, array $columns, array $conditions) {
$userRow = $this->sql->select(
// User meta
"User.uid as userId", "User.name", "User.email", "User.fullName", "User.profilePicture", "User.confirmed",
// GPG
"User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint",
"GpgKey.expires as gpg_expires", "GpgKey.algorithm as gpg_algorithm",
// 2FA
"User.2fa_id", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data", "2FA.type as 2fa_type",
// Language
"Language.uid as langId", "Language.code as langCode", "Language.name as langName",
// additional data
...$columns)
->from("User")
->innerJoin("$table", "$table.user_id", "User.uid")
->leftJoin("Language", "User.language_id", "Language.uid")
->leftJoin("GpgKey", "User.gpg_id", "GpgKey.uid")
->leftJoin("2FA", "User.2fa_id", "2FA.uid")
->where(new CondAnd(...$conditions))
->first()
->execute();
if ($userRow === null || $userRow === false) {
return false;
}
// Meta data
$userId = $userRow["userId"];
$this->uid = $userId;
$this->username = $userRow['name'];
$this->fullName = $userRow["fullName"];
$this->email = $userRow['email'];
$this->profilePicture = $userRow["profilePicture"];
// GPG
if (!empty($userRow["gpg_id"])) {
$this->gpgKey = new GpgKey($userRow["gpg_id"], $this->sql->parseBool($userRow["gpg_confirmed"]),
$userRow["gpg_fingerprint"], $userRow["gpg_algorithm"], $userRow["gpg_expires"]
);
}
// 2FA
if (!empty($userRow["2fa_id"])) {
$this->twoFactorToken = TwoFactorToken::newInstance($userRow["2fa_type"], $userRow["2fa_data"],
$userRow["2fa_id"], $this->sql->parseBool($userRow["2fa_confirmed"]));
}
// Language
if (!is_null($userRow['langId'])) {
$this->setLanguage(Language::newInstance($userRow['langId'], $userRow['langCode'], $userRow['langName']));
}
// select groups
$groupRows = $this->sql->select("Group.uid as groupId", "Group.name as groupName")
->from("UserGroup")
->where(new Compare("UserGroup.user_id", $userId))
->innerJoin("Group", "UserGroup.group_id", "Group.uid")
->execute();
if (is_array($groupRows)) {
foreach ($groupRows as $row) {
$this->groups[$row["groupId"]] = $row["groupName"];
}
}
return $userRow;
}
public function loadApiKey($apiKey): bool {
if ($this->loggedIn) {
return true;
}
$userRow = $this->loadUser("ApiKey", [], [
new Compare("ApiKey.api_key", $apiKey),
new Compare("valid_until", $this->sql->currentTimestamp(), ">"),
new Compare("ApiKey.active", 1),
]);
// User must be confirmed to use API-Keys
if ($userRow === false || !$this->sql->parseBool($userRow["confirmed"])) {
return false;
}
return true;
}
public function processVisit() {
if ($this->sql && $this->sql->isConnected() && isset($_COOKIE["PHPSESSID"]) && !empty($_COOKIE["PHPSESSID"])) {
if ($this->isBot()) {
return;
}
$cookie = $_COOKIE["PHPSESSID"];
$req = new \Api\Visitors\ProcessVisit($this);
$req->execute(array("cookie" => $cookie));
}
}
private function isBot(): bool {
if (empty($_SERVER["HTTP_USER_AGENT"])) {
return false;
}
return preg_match('/robot|spider|crawler|curl|^$/i', $_SERVER['HTTP_USER_AGENT']) === 1;
}
}