diff options
Diffstat (limited to 'src/application/mystic')
-rw-r--r-- | src/application/mystic/forum/Database.php | 194 | ||||
-rw-r--r-- | src/application/mystic/forum/Messaging.php | 6 | ||||
-rw-r--r-- | src/application/mystic/forum/attributes/DefaultValue.php | 10 | ||||
-rw-r--r-- | src/application/mystic/forum/attributes/References.php | 14 | ||||
-rw-r--r-- | src/application/mystic/forum/attributes/Unique.php | 6 | ||||
-rw-r--r-- | src/application/mystic/forum/orm/Attachment.php | 18 | ||||
-rw-r--r-- | src/application/mystic/forum/orm/Post.php | 6 | ||||
-rw-r--r-- | src/application/mystic/forum/orm/Topic.php | 5 | ||||
-rw-r--r-- | src/application/mystic/forum/orm/User.php | 16 | ||||
-rw-r--r-- | src/application/mystic/forum/orm/UserPermissions.php | 59 | ||||
-rw-r--r-- | src/application/mystic/forum/utils/RequestUtils.php | 77 | ||||
-rw-r--r-- | src/application/mystic/forum/utils/ValidationUtils.php | 22 |
12 files changed, 378 insertions, 55 deletions
diff --git a/src/application/mystic/forum/Database.php b/src/application/mystic/forum/Database.php index 9b9cf55..7c9ac7a 100644 --- a/src/application/mystic/forum/Database.php +++ b/src/application/mystic/forum/Database.php @@ -6,10 +6,12 @@ namespace mystic\forum; use DateTime; use DateTimeImmutable; use mystic\forum\attributes\Column; +use mystic\forum\attributes\DefaultValue; use mystic\forum\attributes\NotNull; use mystic\forum\attributes\PrimaryKey; use mystic\forum\attributes\References; use mystic\forum\attributes\Table; +use mystic\forum\attributes\Unique; use mystic\forum\exceptions\DatabaseConnectionException; use mystic\forum\orm\Entity; use mystic\forum\utils\ArrayUtils; @@ -25,6 +27,25 @@ class Database { private const PRIMARY_KEY = 0b0000_0001; private const NOT_NULL = 0b0000_0010; private const REFERENCES = 0b0000_0100; + private const UNIQUE = 0b0000_1000; + private const DEFAULT = 0b0001_0000; + + protected static function encodeBinary(string $bin): string { + return "\\x" . bin2hex($bin); + } + + protected static function decodeBinary(string $enc): string { + return hex2bin(substr($enc, 2)); + } + + public static function generateId(int $length = 64): string { + static $charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + static $charsetLength = strlen($charset); + $buf = ""; + for ($i = 0; $i < $length; ++$i) + $buf .= $charset[random_int(0, $charsetLength - 1)]; + return $buf; + } public function __construct(string $connectionString) { try { @@ -136,6 +157,14 @@ class Database { $colType = self::getColumnType($rflProp); $statement = "$colName $colType"; + if (($colInfo["flags"] & self::DEFAULT) !== 0) { + $statement .= " DEFAULT " . $colInfo["defaultValue"]; + } + + if (($colInfo["flags"] & self::UNIQUE) !== 0) { + $statement .= " UNIQUE"; + } + if (($colInfo["flags"] & self::NOT_NULL) !== 0) { $statement .= " NOT NULL"; } @@ -159,15 +188,21 @@ class Database { $flags = 0; $attrs = $prop->getAttributes(); $reference = ""; + $defaultValue = null; foreach ($attrs as $attr) { $attrName = $attr->getName(); if ($attrName === PrimaryKey::class) { $flags |= self::PRIMARY_KEY; } elseif ($attrName === NotNull::class) { $flags |= self::NOT_NULL; + } elseif ($attrName === Unique::class) { + $flags |= self::UNIQUE; } elseif ($attrName === References::class) { $flags |= self::REFERENCES; $reference = $attr->newInstance()->__toString(); + } elseif ($attrName === DefaultValue::class) { + $flags |= self::DEFAULT; + $defaultValue = $attr->newInstance()->defaultValue; } } @@ -175,6 +210,8 @@ class Database { "propertyName" => $prop->getName(), "flags" => $flags, "reference" => $reference, + "defaultValue" => $defaultValue, + "columnType" => self::getColumnType($prop), ]]; }, $rflEntity->getProperties(ReflectionProperty::IS_PUBLIC))); } @@ -186,8 +223,10 @@ class Database { return null; } - private static function stringifyValue(mixed $value): ?string { - if (is_null($value)) + private static function stringifyValue(mixed $value, string $columnType): ?string { + if ($columnType === "bytea" && is_string($value)) + return self::encodeBinary($value); + elseif (is_null($value)) return null; elseif (is_bool($value)) return $value ? "true" : "false"; @@ -201,6 +240,7 @@ class Database { private static function assignValue(Entity &$entity, array $colProps, ?string $value): void { $propName = $colProps["propertyName"]; + $colType = $colProps["columnType"]; $prop = new \ReflectionProperty($entity, $propName); $type = $prop->getType(); @@ -210,38 +250,43 @@ class Database { $typeName = $type->getName(); if (!$type->isBuiltin() && $typeName !== \DateTime::class && $typeName !== \DateTimeImmutable::class) throw new \RuntimeException("User-defined types cannot be converted to from a database type."); - switch ($typeName) { - case \DateTime::class: - $typedValue = new \DateTime($value); - break; - case \DateTimeImmutable::class: - $typedValue = new \DateTimeImmutable($value); - break; - case "true": - case "false": - case "bool": - $typedValue = in_array(strtolower($value), ["true","yes","on","1"]) ? true : (in_array(strtolower($value), ["false","no","off","0"]) ? false : null); - break; - case "float": - $typedValue = floatval($value); - break; - case "int": - $typedValue = intval($value); - break; - case "string": - case "null": - $typedValue = strval($value); - break; - case "object": - case "array": - case "iterable": - case "callable": - case "mixed": - case "never": - case "void": - default: - throw new \RuntimeException("The type \"$typeName\" cannot be restored from the database"); - } + if ($value !== null) + switch ($typeName) { + case \DateTime::class: + $typedValue = new \DateTime($value); + break; + case \DateTimeImmutable::class: + $typedValue = new \DateTimeImmutable($value); + break; + case "true": + case "false": + case "bool": + $typedValue = in_array(strtolower($value), ["true","t","yes","on","1"]) ? true : (in_array(strtolower($value), ["false","f","no","off","0"]) ? false : null); + break; + case "float": + $typedValue = floatval($value); + break; + case "int": + $typedValue = intval($value); + break; + case "string": + case "null": + if ($colType === "bytea") { + $typedValue = self::decodeBinary(strval($value)); + } else { + $typedValue = strval($value); + } + break; + case "object": + case "array": + case "iterable": + case "callable": + case "mixed": + case "never": + case "void": + default: + throw new \RuntimeException("The type \"$typeName\" cannot be restored from the database"); + } $entity->{$propName} = $typedValue; } @@ -249,7 +294,7 @@ class Database { $values = []; foreach ($cols as $colInfo) { - $values []= self::stringifyValue($entity->{$colInfo["propertyName"]} ?? null); + $values []= self::stringifyValue($entity->{$colInfo["propertyName"]} ?? null, $colInfo["columnType"]); } return $values; @@ -288,6 +333,67 @@ class Database { return true; } + public function fetchWhere(Entity &$entity, string $columnName): bool { + $entityClassName = get_class($entity); + $tableName = self::getTableName($entityClassName); + $reflClass = new ReflectionClass($entityClassName); + $cols = self::getColumns($reflClass); + if (!isset($cols[$columnName])) + throw new \RuntimeException("Column $columnName does not exist!"); + $query = "SELECT * FROM $tableName WHERE $columnName = \$1 LIMIT 1;"; + $result = \pg_query_params($this->connection, $query, [ $entity->{$cols[$columnName]["propertyName"]} ]); + if ($result === false) + throw new \RuntimeException("Fetch failed: " . \pg_last_error($this->connection)); + $row = \pg_fetch_assoc($result); + \pg_free_result($result); + if ($row === false) + return false; + foreach ($cols as $colName => $colProps) + self::assignValue($entity, $colProps, $row[$colName]); + return true; + } + + public function fetchAll(string $entityClassName): array { + $tableName = self::getTableName($entityClassName); + $reflClass = new ReflectionClass($entityClassName); + $cols = self::getColumns($reflClass); + $query = "SELECT * FROM $tableName;"; + $result = \pg_query($this->connection, $query); + if ($result === false) + throw new \RuntimeException("Fetch failed: " . \pg_last_error($this->connection)); + $items = []; + while (($row = \pg_fetch_assoc($result)) !== false) { + $entity = new $entityClassName(); + foreach ($cols as $colName => $colProps) + self::assignValue($entity, $colProps, $row[$colName]); + $items []= $entity; + } + \pg_free_result($result); + return $items; + } + + public function fetchCustom(string $entityClassName, string $customQuery, ?array $customQueryParams = null): array { + $tableName = self::getTableName($entityClassName); + $reflClass = new ReflectionClass($entityClassName); + $cols = self::getColumns($reflClass); + $query = "SELECT * FROM $tableName $customQuery;"; + if ($customQueryParams === null) + $result = \pg_query($this->connection, $query); + else + $result = \pg_query_params($this->connection, $query, $customQueryParams); + if ($result === false) + throw new \RuntimeException("Fetch failed: " . \pg_last_error($this->connection)); + $items = []; + while (($row = \pg_fetch_assoc($result)) !== false) { + $entity = new $entityClassName(); + foreach ($cols as $colName => $colProps) + self::assignValue($entity, $colProps, $row[$colName]); + $items []= $entity; + } + \pg_free_result($result); + return $items; + } + public function delete(Entity &$entity): bool { $entityClassName = get_class($entity); $tableName = self::getTableName($entityClassName); @@ -313,14 +419,20 @@ class Database { if ($primaryCol === null) throw new \RuntimeException("Updating an entity requires a primary key column to be specified"); - $filteredCols = array_filter(ArrayUtils::asPairs($cols), function($pair) { + $filteredCols = array_values(array_filter(ArrayUtils::asPairs($cols), function($pair) { return ($pair[1]["flags"] & self::PRIMARY_KEY) === 0; - }); - $colDef = implode(", ", array_map(function(array $pair): string { - return implode("=", [ $pair[0], '$' . $pair[1] ]); - }, $filteredCols)); + })); + // echo "<pre>"; + // var_dump($filteredCols); + // exit; + $qCols = []; + for ($i = 0; $i < count($filteredCols); ++$i) { + $qCols []= implode("=", [ $filteredCols[$i][0], '$' . ($i + 2) ]); + } + $colDef = implode(", ", $qCols); $query = "UPDATE $tableName SET $colDef WHERE $primaryCol = \$1;"; - $result = \pg_query_params($this->connection, $query, array_map(fn($c) => $entity->{$c["propertyName"]}, $filteredCols)); + //$theCols = ArrayUtils::assocFromPairs($filteredCols); + $result = \pg_query_params($this->connection, $query, self::getColumnValues($entity, $cols)); if ($result === false) throw new \RuntimeException("Update failed: " . \pg_last_error($this->connection)); $num_affected_rows = \pg_affected_rows($result); diff --git a/src/application/mystic/forum/Messaging.php b/src/application/mystic/forum/Messaging.php index 2f5c80d..3530d5e 100644 --- a/src/application/mystic/forum/Messaging.php +++ b/src/application/mystic/forum/Messaging.php @@ -11,7 +11,7 @@ final class Messaging { const ENT_FLAGS = \ENT_COMPAT | \ENT_HTML401 | \ENT_SUBSTITUTE; protected static function message(array $items, string $headerText, string $headerColor, string $headerTextColor): void { - echo "<HTML><BODY>\n"; + //echo "<HTML><BODY>\n"; echo "<TABLE cellspacing=2 cellpadding=0 bgcolor=gray><TR><TD>\n"; echo "<TABLE width=\"100%\" cellspacing=0 cellpadding=4 bgcolor=\"".htmlentities($headerColor, self::ENT_FLAGS)."\"><TR><TD align=center>\n"; @@ -20,7 +20,7 @@ final class Messaging { echo "</TD></TR><TR><TD>\n"; - echo "<TABLE cellspacing=0 cellpadding=4 bgcolor=WHITE><TR><TD align=left><FONT size=2 face=Arial color=BLACK>\n"; + echo "<TABLE width=\"100%\" cellspacing=0 cellpadding=4 bgcolor=WHITE><TR><TD align=left><FONT size=2 face=Arial color=BLACK>\n"; foreach ($items as $item) { if (is_scalar($item)) { @@ -65,7 +65,7 @@ final class Messaging { echo "</FONT></TD></TR></TABLE>\n"; echo "</TD></TR></TABLE>\n"; - echo "</BODY></HTML>\n"; + //echo "</BODY></HTML>\n"; } public static function bold(string $contents): array { diff --git a/src/application/mystic/forum/attributes/DefaultValue.php b/src/application/mystic/forum/attributes/DefaultValue.php new file mode 100644 index 0000000..e3dd1a5 --- /dev/null +++ b/src/application/mystic/forum/attributes/DefaultValue.php @@ -0,0 +1,10 @@ +<?php + +namespace mystic\forum\attributes; + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class DefaultValue { + public function __construct( + public readonly string $defaultValue, + ) {} +} diff --git a/src/application/mystic/forum/attributes/References.php b/src/application/mystic/forum/attributes/References.php index 9e33927..8271ebb 100644 --- a/src/application/mystic/forum/attributes/References.php +++ b/src/application/mystic/forum/attributes/References.php @@ -4,15 +4,25 @@ namespace mystic\forum\attributes; #[\Attribute(\Attribute::TARGET_PROPERTY)] class References { + public const NO_ACTION = 0; + public const CASCADE = 1; + public const SET_NULL = 2; + public const SET_DEFAULT = 3; + public function __construct( public readonly string $foreignTableName, public readonly ?string $foreignColumnName = null, - public readonly bool $cascadeOnDelete = false, + public readonly int $onDelete = self::NO_ACTION, ) {} public function __toString(): string { return $this->foreignTableName . ($this->foreignColumnName !== null ? " ({$this->foreignColumnName})" : "") - . ($this->cascadeOnDelete ? " ON DELETE CASCADE" : ""); + . ([ + self::NO_ACTION => "", + self::CASCADE => " ON DELETE CASCADE", + self::SET_NULL => " ON DELETE SET NULL", + self::SET_DEFAULT => " ON DELETE SET DEFAULT", + ][$this->onDelete] ?? ""); } } diff --git a/src/application/mystic/forum/attributes/Unique.php b/src/application/mystic/forum/attributes/Unique.php new file mode 100644 index 0000000..7c32255 --- /dev/null +++ b/src/application/mystic/forum/attributes/Unique.php @@ -0,0 +1,6 @@ +<?php + +namespace mystic\forum\attributes; + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class Unique {} diff --git a/src/application/mystic/forum/orm/Attachment.php b/src/application/mystic/forum/orm/Attachment.php new file mode 100644 index 0000000..64e357c --- /dev/null +++ b/src/application/mystic/forum/orm/Attachment.php @@ -0,0 +1,18 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\orm; + +use mystic\forum\attributes\Column; +use mystic\forum\attributes\PrimaryKey; +use mystic\forum\attributes\References; +use mystic\forum\attributes\Table; + +#[Table("public.attachments")] +class Attachment extends Entity { + #[PrimaryKey] public string $id; + #[References("public.posts", onDelete: References::CASCADE)] public string $postId; + public string $name; + public string $mimeType; + #[Column(columnType: "bytea")] public string $contents; +} diff --git a/src/application/mystic/forum/orm/Post.php b/src/application/mystic/forum/orm/Post.php index 2c9a38c..becbb9d 100644 --- a/src/application/mystic/forum/orm/Post.php +++ b/src/application/mystic/forum/orm/Post.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace mystic\forum\orm; +use mystic\forum\attributes\DefaultValue; use mystic\forum\attributes\PrimaryKey; use mystic\forum\attributes\References; use mystic\forum\attributes\Table; @@ -11,7 +12,8 @@ use mystic\forum\attributes\Table; class Post extends Entity { #[PrimaryKey] public string $id; public string $content; - #[References("public.users")] public string $authorId; + #[References("public.users", onDelete: References::SET_NULL)] public ?string $authorId; public \DateTimeImmutable $postDate; - #[References("public.topics", cascadeOnDelete: true)] public string $topicId; + #[References("public.topics", onDelete: References::CASCADE)] public string $topicId; + #[DefaultValue("false")] public bool $deleted; } diff --git a/src/application/mystic/forum/orm/Topic.php b/src/application/mystic/forum/orm/Topic.php index 202fc50..421f5ec 100644 --- a/src/application/mystic/forum/orm/Topic.php +++ b/src/application/mystic/forum/orm/Topic.php @@ -4,12 +4,13 @@ declare(strict_types=1); namespace mystic\forum\orm; use mystic\forum\attributes\PrimaryKey; +use mystic\forum\attributes\References; use mystic\forum\attributes\Table; #[Table("public.topics")] class Topic extends Entity { - #[PrimaryKey] - public string $id; + #[PrimaryKey] public string $id; public string $title; public \DateTimeImmutable $creationDate; + #[References("public.users", onDelete: References::SET_NULL)] public ?string $createdBy; } diff --git a/src/application/mystic/forum/orm/User.php b/src/application/mystic/forum/orm/User.php index 3b531b4..1db1d04 100644 --- a/src/application/mystic/forum/orm/User.php +++ b/src/application/mystic/forum/orm/User.php @@ -5,13 +5,27 @@ namespace mystic\forum\orm; use mystic\forum\attributes\PrimaryKey; use mystic\forum\attributes\Table; +use mystic\forum\attributes\Unique; #[Table("public.users")] class User extends Entity { + public const SUPERUSER_ID = "SUPERUSER"; + #[PrimaryKey] public string $id; - public string $name; + #[Unique] public string $name; public string $displayName; + #[Unique] public string $email; public \DateTimeImmutable $created; public string $passwordHash; + public int $permissionMask; + public bool $passwordResetRequired; + public string $activationToken; + public bool $activated; + + public function hasPermission(int $perm): bool { + if ($this->id === self::SUPERUSER_ID) + return true; + return ($this->permissionMask & $perm) === $perm; + } } diff --git a/src/application/mystic/forum/orm/UserPermissions.php b/src/application/mystic/forum/orm/UserPermissions.php new file mode 100644 index 0000000..cd2fdf4 --- /dev/null +++ b/src/application/mystic/forum/orm/UserPermissions.php @@ -0,0 +1,59 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\orm; + +use mystic\forum\utils\StaticClass; + +final class UserPermissions { + use StaticClass; + + public const CREATE_OWN_POST = 0x01; + public const EDIT_OWN_POST = 0x02; + public const DELETE_OWN_POST = 0x04; + + public const CREATE_OWN_TOPIC = 0x08; + public const EDIT_OWN_TOPIC = 0x10; + public const DELETE_OWN_TOPIC = 0x20; + + public const CREATE_OWN_ATTACHMENT = 0x40; + public const EDIT_OWN_ATTACHMENT = 0x80; + public const DELETE_OWN_ATTACHMENT = 0x100; + + // public const CREATE_OWN_USER = n/a; + public const EDIT_OWN_USER = 0x200; + public const DELETE_OWN_USER = 0x400; + + // public const CREATE_OTHER_POST = n/a; + public const EDIT_OTHER_POST = 0x800; + public const DELETE_OTHER_POST = 0x1000; + + public const CREATE_OTHER_USER = 0x2000; + public const EDIT_OTHER_USER = 0x4000; + public const DELETE_OTHER_USER = 0x8000; + + public const DELETE_OTHER_TOPIC = 0x10000; + + //////// + + public const GROUP_USER = self::CREATE_OWN_POST + | self::EDIT_OWN_POST + | self::DELETE_OWN_POST + | self::CREATE_OWN_TOPIC + | self::DELETE_OWN_TOPIC + | self::CREATE_OWN_ATTACHMENT + | self::EDIT_OWN_ATTACHMENT + | self::DELETE_OWN_ATTACHMENT + | self::EDIT_OWN_USER + | self::DELETE_OWN_USER; + + public const GROUP_MOD = self::GROUP_USER + | self::EDIT_OTHER_POST + | self::DELETE_OTHER_USER + | self::DELETE_OTHER_TOPIC; + + public const GROUP_ADMIN = self::GROUP_MOD + | self::CREATE_OTHER_USER + | self::EDIT_OTHER_USER + | self::DELETE_OTHER_USER; +} diff --git a/src/application/mystic/forum/utils/RequestUtils.php b/src/application/mystic/forum/utils/RequestUtils.php index 2f40013..f6ce3a3 100644 --- a/src/application/mystic/forum/utils/RequestUtils.php +++ b/src/application/mystic/forum/utils/RequestUtils.php @@ -3,17 +3,86 @@ declare(strict_types=1); namespace mystic\forum\utils; +use mystic\forum\Database; use mystic\forum\Messaging; +use mystic\forum\orm\User; final class RequestUtils { use StaticClass; + public static function getRequestMethod(): string { + return strtoupper($_SERVER["REQUEST_METHOD"] ?? "GET"); + } + + public static function isRequestMethod(string $method): bool { + $rMethod = self::getRequestMethod(); + return strcasecmp($rMethod, $method) === 0; + } + public static function ensureRequestMethod(string $method): void { - $rMethod = $_SERVER["REQUEST_METHOD"]; - if (strcasecmp($rMethod, $method) !== 0) { - http_response_code(500); - Messaging::error("Invalid request method $rMethod"); + if (!self::isRequestMethod($method)) { + http_response_code(415); + Messaging::error("Invalid request method " . self::getRequestMethod()); + exit; + } + } + + public static function getRequiredField(string $field): string { + $fieldValue = $_POST[$field] ?? null; + if ($fieldValue === null) { + http_response_code(400); + Messaging::error("Missing required field $field"); exit; } + return $fieldValue; + } + + public static function storeForm(): void { + $_SESSION["lastForm"] = $_POST ?? []; + $_SESSION["lastForm_uri"] = $_SERVER["REQUEST_URI"]; + } + + public static function triggerFormError(string $message, ?string $next = null): never { + $next ??= $_SERVER["REQUEST_URI"]; + $_SESSION["formError"] = $message; + // store last form submission + self::storeForm(); + header("Location: $next"); + exit; + } + + public static function getAndClearFormError(): ?string { + $err = $_SESSION["formError"] ?? null; + unset($_SESSION["formError"]); + return $err; + } + + public static function getLastForm(string &$lastFormUri): ?array { + $lastFormUri = $_SESSION["lastForm_uri"] ?? ""; + return $_SESSION["lastForm"] ?? null; + } + + public static function clearLastForm(): void { + unset($_SESSION["lastForm"]); + unset($_SESSION["lastForm_uri"]); + } + + public static function getAuthorizedUser(Database &$db): ?User { + $userId = $_SESSION["authedUser"] ?? null; + if ($userId === null) + return null; + $user = new User(); + $user->id = $userId; + if (!$db->fetch($user)) + return null; + return $user; + } + + public static function setAuthorizedUser(User &$user): void { + $_SESSION["authedUser"] = $user->id; + } + + public static function unsetAuthorizedUser(): void { + unset($_SESSION["authedUser"]); } } diff --git a/src/application/mystic/forum/utils/ValidationUtils.php b/src/application/mystic/forum/utils/ValidationUtils.php new file mode 100644 index 0000000..df97914 --- /dev/null +++ b/src/application/mystic/forum/utils/ValidationUtils.php @@ -0,0 +1,22 @@ +<?php +declare(strict_types=1); + +namespace mystic\forum\utils; + +use mystic\forum\Database; +use mystic\forum\orm\User; + +final class ValidationUtils { + use StaticClass; + + public static function isUsernameValid(string $name): bool { + return !!preg_match('/^[a-z0-9]([._](?![._])|[a-z0-9]){2,30}[a-z0-9]$/', $name); + } + + public static function isUsernameAvailable(Database &$db, string $name): bool { + $user = new User(); + $user->name = $name; + return !$db->fetchWhere($user, "name"); + } +} + |