summaryrefslogtreecommitdiff
path: root/src/application
diff options
context:
space:
mode:
authorJonas Kohl2024-09-12 19:49:17 +0200
committerJonas Kohl2024-09-12 19:49:17 +0200
commit086e2d2668784469ec114f6e6fd2b3dace3d7c3b (patch)
treeb9bacedb713501d88d24085940267a7c94e69b29 /src/application
parent34b1b391d4b03659a96f868857c230002b351514 (diff)
Way more progress on forum
Diffstat (limited to 'src/application')
-rw-r--r--src/application/mystic/forum/Database.php194
-rw-r--r--src/application/mystic/forum/Messaging.php6
-rw-r--r--src/application/mystic/forum/attributes/DefaultValue.php10
-rw-r--r--src/application/mystic/forum/attributes/References.php14
-rw-r--r--src/application/mystic/forum/attributes/Unique.php6
-rw-r--r--src/application/mystic/forum/orm/Attachment.php18
-rw-r--r--src/application/mystic/forum/orm/Post.php6
-rw-r--r--src/application/mystic/forum/orm/Topic.php5
-rw-r--r--src/application/mystic/forum/orm/User.php16
-rw-r--r--src/application/mystic/forum/orm/UserPermissions.php59
-rw-r--r--src/application/mystic/forum/utils/RequestUtils.php77
-rw-r--r--src/application/mystic/forum/utils/ValidationUtils.php22
-rw-r--r--src/application/views/alert_error.php3
-rw-r--r--src/application/views/form_addpost.php27
-rw-r--r--src/application/views/form_delete_post_confirm.php25
-rw-r--r--src/application/views/form_login.php40
-rw-r--r--src/application/views/form_newtopic.php24
-rw-r--r--src/application/views/form_register.php55
-rw-r--r--src/application/views/nav_guest.php6
-rw-r--r--src/application/views/nav_logged_in.php14
-rw-r--r--src/application/views/template_end.php22
-rw-r--r--src/application/views/template_navigation.php6
-rw-r--r--src/application/views/template_navigation_end.php4
-rw-r--r--src/application/views/template_navigation_start.php12
-rw-r--r--src/application/views/template_start.php22
-rw-r--r--src/application/views/view_post.php86
-rw-r--r--src/application/views/view_topic_end.php0
-rw-r--r--src/application/views/view_topic_start.php55
-rw-r--r--src/application/views/view_topics.php19
-rw-r--r--src/application/views/view_user.php1
30 files changed, 799 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");
+ }
+}
+
diff --git a/src/application/views/alert_error.php b/src/application/views/alert_error.php
new file mode 100644
index 0000000..c18546b
--- /dev/null
+++ b/src/application/views/alert_error.php
@@ -0,0 +1,3 @@
+<div class="alert alert-danger" role="alert">
+<?= htmlentities($message); ?>
+</div>
diff --git a/src/application/views/form_addpost.php b/src/application/views/form_addpost.php
new file mode 100644
index 0000000..b3cd6ca
--- /dev/null
+++ b/src/application/views/form_addpost.php
@@ -0,0 +1,27 @@
+<?php
+
+use mystic\forum\utils\RequestUtils;
+
+$lastFormUri = "";
+$lastForm = RequestUtils::getLastForm($lastFormUri) ?? [];
+if ($lastFormUri !== $_SERVER["REQUEST_URI"]) $lastForm = [];
+RequestUtils::clearLastForm();
+
+?>
+<h3 id="form">Reply to this topic</h3>
+<?php
+if (($_formError = RequestUtils::getAndClearFormError()) !== null) {
+ _view("alert_error", ["message" => $_formError]);
+}
+?>
+<form action="<?= htmlentities($_SERVER["REQUEST_URI"]) ?>#form" method="post" enctype="multipart/form-data">
+<div class="form-group">
+ <label for="i_message">Message:</label>
+ <textarea class="form-control" id="i_message" name="message" required rows="12" cols="60" style="resize:vertical;max-height:499px"></textarea>
+</div>
+<div class="form-group">
+ <label for="i_files">Attachments:</label>
+ <input type="file" name="files[]" id="i_files" multiple accept="*/*">
+</div>
+<button type="submit" class="btn btn-success">Post reply</button>
+</form>
diff --git a/src/application/views/form_delete_post_confirm.php b/src/application/views/form_delete_post_confirm.php
new file mode 100644
index 0000000..9d04095
--- /dev/null
+++ b/src/application/views/form_delete_post_confirm.php
@@ -0,0 +1,25 @@
+<div class="panel panel-danger">
+ <div class="panel-heading">
+ <h3 class="panel-title">Do you want to delete your post?</h3>
+ </div>
+ <div class="panel-body">
+ Are you sure you want to delete the following post:
+ <div class="well">
+ <?= renderPost($post->content); ?>
+ </div>
+ </div>
+ <div class="panel-footer">
+ <div class="text-right">
+ <form action="." method="get" class="seamless-inline">
+ <input type="hidden" name="_action" value="viewtopic">
+ <input type="hidden" name="topic" value="<?= htmlentities($post->topicId) ?>">
+ <button class="btn btn-default">Keep my post</button>
+ </form>
+ <form action="?_action=deletepost" method="post" class="seamless-inline">
+ <input type="hidden" name="post" value="<?= htmlentities($post->id) ?>">
+ <input type="hidden" name="confirm" value="<?= htmlentities(base64_encode(hash("sha256", "confirm" . $post->id, true))); ?>">
+ <button class="btn btn-danger">Delete my post</button>
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/src/application/views/form_login.php b/src/application/views/form_login.php
new file mode 100644
index 0000000..0e98a24
--- /dev/null
+++ b/src/application/views/form_login.php
@@ -0,0 +1,40 @@
+<?php
+
+use mystic\forum\Messaging;
+use mystic\forum\utils\RequestUtils;
+
+$lastFormUri = "";
+$lastForm = RequestUtils::getLastForm($lastFormUri) ?? [];
+if ($lastFormUri !== $_SERVER["REQUEST_URI"]) $lastForm = [];
+RequestUtils::clearLastForm();
+
+?>
+<h1>Log in</h1>
+<div class="col-md-4"></div>
+<div class="well col-md-4">
+<?php
+if (($_formError = RequestUtils::getAndClearFormError()) !== null) {
+ _view("alert_error", ["message" => $_formError]);
+}
+?>
+<form action="<?= htmlentities($_SERVER["REQUEST_URI"]) ?>" method="post">
+<div class="form-group">
+ <label for="i_username">Username:</label>
+ <input class="form-control" type="text" id="i_username" name="username" value="<?= htmlentities($lastForm["username"] ?? "") ?>" required>
+</div>
+
+<div class="form-group">
+ <label for="i_password">Password:</label>
+ <input class="form-control" type="password" id="i_password" name="password" required>
+</div>
+
+<div class="form-group">
+ <button class="btn btn-default" type="submit">Log in</button>
+</div>
+
+<div class="form-group">
+ Don't have an account? <a href="?_action=register">Register now</a>
+</div>
+</form>
+</div>
+<div class="col-md-4"></div>
diff --git a/src/application/views/form_newtopic.php b/src/application/views/form_newtopic.php
new file mode 100644
index 0000000..d5cbfbd
--- /dev/null
+++ b/src/application/views/form_newtopic.php
@@ -0,0 +1,24 @@
+<?php
+
+use mystic\forum\Messaging;
+use mystic\forum\utils\RequestUtils;
+
+if (($_formError = RequestUtils::getAndClearFormError()) !== null) {
+ Messaging::error($_formError);
+}
+
+$lastFormUri = "";
+$lastForm = RequestUtils::getLastForm($lastFormUri) ?? [];
+if ($lastFormUri !== $_SERVER["REQUEST_URI"]) $lastForm = [];
+RequestUtils::clearLastForm();
+
+?>
+<form action="<?= htmlentities($_SERVER["REQUEST_URI"]) ?>" method="post">
+<strong>Topic title:</strong><br>
+<input type="text" name="title" value="<?= htmlentities($lastForm["title"] ?? "") ?>" required><br>
+
+<strong>Message:</strong><br>
+<textarea name="message" rows="12" cols="60" required></textarea><br>
+
+<button type="submit">Create topic</button>
+</form>
diff --git a/src/application/views/form_register.php b/src/application/views/form_register.php
new file mode 100644
index 0000000..221f37d
--- /dev/null
+++ b/src/application/views/form_register.php
@@ -0,0 +1,55 @@
+<?php
+
+use mystic\forum\Messaging;
+use mystic\forum\utils\RequestUtils;
+
+$lastFormUri = "";
+$lastForm = RequestUtils::getLastForm($lastFormUri) ?? [];
+if ($lastFormUri !== $_SERVER["REQUEST_URI"]) $lastForm = [];
+RequestUtils::clearLastForm();
+
+?>
+<h1>Register</h1>
+<div class="col-md-4"></div>
+<div class="well col-md-4">
+<?php
+if (($_formError = RequestUtils::getAndClearFormError()) !== null) {
+ _view("alert_error", ["message" => $_formError]);
+}
+?>
+<form action="<?= htmlentities($_SERVER["REQUEST_URI"]) ?>" method="post">
+<div class="form-group">
+ <label for="i_username">Username:</label>
+ <input class="form-control" id="i_username" type="text" name="username" value="<?= htmlentities($lastForm["username"] ?? "") ?>" required>
+</div>
+
+<div class="form-group">
+ <label for="i_display_name">Display name:</label>
+ <input class="form-control" id="i_display_name" type="text" name="display_name" value="<?= htmlentities($lastForm["display_name"] ?? "") ?>" required>
+</div>
+
+<div class="form-group">
+ <label for="i_password">Choose password:</label>
+ <input class="form-control" id="i_password" type="password" name="password" required>
+</div>
+
+<div class="form-group">
+ <label for="i_password_retype">Repeat password:</label>
+ <input class="form-control" id="i_password_retype" type="password" name="password_retype" required>
+</div>
+
+<div class="form-group">
+ <label for="i_email">Email address:</label>
+ <input class="form-control" id="i_email" type="email" name="email" value="<?= htmlentities($lastForm["email"] ?? "") ?>" required>
+</div>
+
+<div class="form-group">
+ <button class="btn btn-default" type="submit">Register now</button>
+</div>
+
+<div class="form-group">
+ Already have an account? <a href="?_action=auth">Sign in now</a>
+</div>
+</form>
+</div>
+<div class="col-md-4"></div>
diff --git a/src/application/views/nav_guest.php b/src/application/views/nav_guest.php
new file mode 100644
index 0000000..433c487
--- /dev/null
+++ b/src/application/views/nav_guest.php
@@ -0,0 +1,6 @@
+<ul class="nav navbar-nav navbar-right">
+<li<?= $GLOBALS["action"] === "auth" ? ' class="active"' : '' ?>><a href="?_action=auth">Log in</a></li>
+<?php if (REGISTRATION_ENABLED): ?>
+<li<?= $GLOBALS["action"] === "register" ? ' class="active"' : '' ?>><a href="?_action=register">Register</a></li>
+<?php endif; ?>
+</ul> \ No newline at end of file
diff --git a/src/application/views/nav_logged_in.php b/src/application/views/nav_logged_in.php
new file mode 100644
index 0000000..fd46d6e
--- /dev/null
+++ b/src/application/views/nav_logged_in.php
@@ -0,0 +1,14 @@
+<?php
+use mystic\forum\orm\User;
+?>
+<ul class="nav navbar-nav navbar-right">
+<li><p class="navbar-text">Welcome,
+<?php if ($user->id === User::SUPERUSER_ID): ?>
+<strong class="text-danger"><?= htmlentities($user->displayName) ?></strong>!
+<?php else: ?>
+<strong><?= htmlentities($user->displayName) ?></strong>!
+<?php endif; ?>
+</p></li>
+<li><a href="?_action=viewuser&amp;user=<?= htmlentities(urlencode($user->id)) ?>"><span class="glyphicon glyphicon-user" aria-hidden="true"><span class="sr-only">View profile</span></a></li>
+<li><a href="?_action=logout"><span class="glyphicon glyphicon-log-out" aria-hidden="true"><span class="sr-only">Log out</span></a></li>
+</ul>
diff --git a/src/application/views/template_end.php b/src/application/views/template_end.php
new file mode 100644
index 0000000..f7f70a9
--- /dev/null
+++ b/src/application/views/template_end.php
@@ -0,0 +1,22 @@
+</div>
+<footer class="footer">
+<div class="container">
+<div class="panel panel-default">
+<div class="panel-body">
+ &copy; <?= date("Y") ?>
+</div>
+</div>
+</div>
+</footer>
+
+<script>
+$(function() {
+ $("._time").each(function(i, e) {
+ var date = new Date($(e).text());
+ $(e).text(date.toLocaleString());
+ });
+});
+</script>
+
+</body>
+</html>
diff --git a/src/application/views/template_navigation.php b/src/application/views/template_navigation.php
new file mode 100644
index 0000000..d39c1ea
--- /dev/null
+++ b/src/application/views/template_navigation.php
@@ -0,0 +1,6 @@
+<?php
+if ($user) {
+ _view("nav_logged_in", ["user" => $user]);
+} else {
+ _view("nav_guest");
+}
diff --git a/src/application/views/template_navigation_end.php b/src/application/views/template_navigation_end.php
new file mode 100644
index 0000000..a43919c
--- /dev/null
+++ b/src/application/views/template_navigation_end.php
@@ -0,0 +1,4 @@
+</div>
+</div>
+</nav>
+<div class="container">
diff --git a/src/application/views/template_navigation_start.php b/src/application/views/template_navigation_start.php
new file mode 100644
index 0000000..48cdb9a
--- /dev/null
+++ b/src/application/views/template_navigation_start.php
@@ -0,0 +1,12 @@
+<nav class="navbar navbar-default navbar-static-top">
+ <div class="container">
+ <div class="navbar-header">
+ <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#nav-collapse" aria-expanded="false">
+ <span class="sr-only">Toggle navigation</span>
+ <span class="icon-bar"></span>
+ <span class="icon-bar"></span>
+ <span class="icon-bar"></span>
+ </button>
+ <a class="navbar-brand" href=".">Forum</a>
+ </div>
+ <div class="collapse navbar-collapse" id="nav-collapse">
diff --git a/src/application/views/template_start.php b/src/application/views/template_start.php
new file mode 100644
index 0000000..e011f74
--- /dev/null
+++ b/src/application/views/template_start.php
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<!--[if lt IE 7]> <html lang="de" class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
+<!--[if IE 7]> <html lang="de" class="no-js lt-ie9 lt-ie8"> <![endif]-->
+<!--[if IE 8]> <html lang="de" class="no-js lt-ie9"> <![endif]-->
+<!--[if gt IE 8]><!--> <html lang="de" class="no-js"> <!--<![endif]-->
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <title><?= htmlentities($_title ?? "") ?></title>
+ <link rel="stylesheet" href="/ui/dist/css/bootstrap.min.css">
+ <link rel="stylesheet" href="/ui/dist/css/bootstrap-theme.min.css">
+ <link rel="stylesheet" href="/ui/site.css">
+ <script src="/ui/jquery-1.12.4.min.js"></script>
+ <script src="/ui/dist/js/bootstrap.min.js"></script>
+ <script src="/ui/modernizr-2.6.2.min.js"></script>
+ <!--[if lt IE 9]>
+ <script src="/ui/html5shiv.min.js"></script>
+ <script src="/ui/respond.min.js"></script>
+ <![endif]-->
+</head>
+<body>
diff --git a/src/application/views/view_post.php b/src/application/views/view_post.php
new file mode 100644
index 0000000..0776fb5
--- /dev/null
+++ b/src/application/views/view_post.php
@@ -0,0 +1,86 @@
+<?php
+
+use mystic\forum\orm\UserPermissions;
+use mystic\forum\orm\Attachment;
+
+$fileAttachments = array_filter($attachments, fn(Attachment $a) => !str_starts_with($a->mimeType, "image/"));
+$imageAttachments = array_filter($attachments, fn(Attachment $a) => str_starts_with($a->mimeType, "image/"));
+
+$canReply = $GLOBALS["currentUser"]?->hasPermission(UserPermissions::CREATE_OWN_POST) ?? false;
+
+$canDelete = ($GLOBALS["currentUser"]?->id === $postAuthor?->id && $postAuthor?->hasPermission(UserPermissions::DELETE_OWN_POST))
+ || ($GLOBALS["currentUser"]?->hasPermission(UserPermissions::DELETE_OTHER_POST));
+
+?>
+
+<?php if ($post->deleted): ?>
+<div class="media">
+<div class="media-left hidden-sm hidden-xs">
+ <div class="media-object" style="width:64px"></div>
+</div>
+<div class="media-body">
+ <div class="well">
+ <em>This post has been deleted</em>
+ </div>
+</div>
+</div>
+<?php else: ?>
+<div class="media">
+ <div class="media-left hidden-sm hidden-xs">
+ <?php if ($postAuthor): ?>
+ <a href="?_action=viewuser&amp;user=<?= htmlentities(urlencode($postAuthor->id)) ?>">
+ <img class="media-object" src="/ui/placeholder.svg" alt="" width="64" height="64">
+ </a>
+ <?php else: ?>
+ <div class="media-object" style="width:64px;height:64px"></div>
+ <?php endif; ?>
+ </div>
+ <div class="media-body">
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ <h3 class="panel-title">
+ <div class="pull-right">
+ <?php if ($canReply): ?>
+ <button data-text="<?= htmlentities($post->content) ?>" class="btn btn-default js-only _reply-post"><span class="glyphicon glyphicon-share-alt" aria-hidden="true"></span><span class="sr-only">Reply to post</span></button>
+ <?php endif; ?>
+ <?php if ($canDelete): ?>
+ <form action="?_action=deletepost" method="post" class="seamless-inline">
+ <input type="hidden" name="post" value="<?= htmlentities($post->id) ?>">
+ <button type="submit" class="btn btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span><span class="sr-only">Delete post</span></button>
+ </form>
+ <?php endif; ?>
+ </div>
+ <?php if ($postAuthor): ?>
+ <a href="?_action=viewuser&amp;user=<?= htmlentities(urlencode($postAuthor->id)) ?>"><?= htmlentities($postAuthor->displayName) ?></a>
+ <?php else: ?>
+ <em class="text-muted">(deleted)</em>
+ <?php endif; ?>
+ </h3>
+ <span class="_time"><?= $post->postDate->format("c"); ?></span>
+ </div>
+ <div class="panel-body">
+ <div class="post-content"><?= renderPost($post->content) ?></div>
+ <?php if (count($imageAttachments) > 0): ?>
+ <div class="post-images clearfix">
+ <?php /** @var Attachment $attachment */ foreach ($imageAttachments as $attachment): ?>
+ <a class="image-attachment" href="?_action=attachment&amp;attachment=<?= htmlentities(urlencode($attachment->id)) ?>" title="<?= htmlentities($attachment->name) ?>">
+ <img class="image-attachment-image" src="?_action=thumb&amp;attachment=<?= htmlentities(urlencode($attachment->id)) ?>" alt="" width="110">
+ </a>
+ <?php endforeach; ?>
+ </div>
+ <?php endif; ?>
+ </div>
+ <?php if (count($fileAttachments) > 0): ?>
+ <div class="panel-footer">
+ <div class="btn-group">
+ <?php /** @var Attachment $attachment */ foreach ($fileAttachments as $attachment): ?>
+ <a class="btn btn-default" href="?_action=attachment&amp;attachment=<?= htmlentities(urlencode($attachment->id)) ?>"><?= htmlentities($attachment->name) ?></a>
+ <?php endforeach; ?>
+ </div>
+ </div>
+ <?php endif; ?>
+ </div>
+ </div>
+</div>
+
+<?php endif; ?>
diff --git a/src/application/views/view_topic_end.php b/src/application/views/view_topic_end.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/application/views/view_topic_end.php
diff --git a/src/application/views/view_topic_start.php b/src/application/views/view_topic_start.php
new file mode 100644
index 0000000..5818483
--- /dev/null
+++ b/src/application/views/view_topic_start.php
@@ -0,0 +1,55 @@
+<?php
+use mystic\forum\orm\UserPermissions;
+
+$canReply = $GLOBALS["currentUser"]?->hasPermission(UserPermissions::CREATE_OWN_POST) ?? false;
+
+$canDelete = ($GLOBALS["currentUser"]?->id === $topicAuthor->id && $topicAuthor->hasPermission(UserPermissions::DELETE_OWN_TOPIC))
+ || ($GLOBALS["currentUser"]?->hasPermission(UserPermissions::DELETE_OTHER_TOPIC));
+?>
+<div role="heading" class="h1">
+<?= htmlentities($topic->title) ?>
+<div class="pull-right">
+<?php if ($canReply): ?>
+<button id="btn-reply" class="btn btn-default js-only"><span class="glyphicon glyphicon-share-alt" aria-hidden="true"></span> Reply</button>
+<?php endif; ?>
+<?php if ($canDelete): ?>
+<form action="?_action=deletetopic" method="post" class="seamless-inline">
+<input type="hidden" name="topic" value="<?= htmlentities($topic->id) ?>">
+<button type="submit" class="btn btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete topic</button>
+</form>
+<?php endif; ?>
+</div>
+</div>
+<p>
+Started by
+<?php if ($topicAuthor !== null): ?>
+<a href="?_action=viewuser&amp;user=<?= htmlentities(urlencode($topicAuthor->id)) ?>"><?= htmlentities($topicAuthor->displayName) ?></a>
+<?php else: ?>
+<em>(deleted)</em>
+<?php endif; ?>
+on <span class="_time"><?= htmlentities($topic->creationDate->format("c")) ?></span>
+</p>
+<?php if ($canReply): ?>
+<script>
+$(function() {
+ function focusReplyBox() {
+ var msgInput = $("#i_message");
+ msgInput[0].scrollIntoView();
+ msgInput.focus();
+ }
+ $("#btn-reply").click(function() {
+ focusReplyBox();
+ });
+ $("._reply-post").click(function() {
+ var text = $(this).attr("data-text");
+ var val = $("#i_message").val();
+ var lines = text.split("\n");
+ for (var i = 0; i < lines.length; ++i)
+ val += "\n> " + lines[i];
+ val += "\n\n";
+ $("#i_message").val(val.replace(/^\n+/, ""));
+ focusReplyBox();
+ });
+});
+</script>
+<?php endif; ?>
diff --git a/src/application/views/view_topics.php b/src/application/views/view_topics.php
new file mode 100644
index 0000000..cb871cb
--- /dev/null
+++ b/src/application/views/view_topics.php
@@ -0,0 +1,19 @@
+<?php
+
+use mystic\forum\orm\User;
+use mystic\forum\orm\UserPermissions;
+
+if ($GLOBALS["currentUser"]?->hasPermission(UserPermissions::CREATE_OWN_TOPIC)):
+?>
+<p class="text-right">
+<a href="?_action=newtopic" class="btn btn-success"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span>&nbsp;New topic</a>
+</p>
+<?php endif; ?>
+<div class="list-group">
+<?php /** @var Topic $topic */ foreach ($topics as $topic): ?>
+<a class="list-group-item" href="?_action=viewtopic&amp;topic=<?= htmlentities(urlencode($topic->id)) ?>">
+ <h4 class="list-group-item-heading"><?= htmlentities($topic->title) ?></h4>
+ <p class="list-group-item-text _time"><?= htmlentities($topic->creationDate->format("c")) ?></p>
+</a>
+<?php endforeach; ?>
+</div>
diff --git a/src/application/views/view_user.php b/src/application/views/view_user.php
new file mode 100644
index 0000000..334928b
--- /dev/null
+++ b/src/application/views/view_user.php
@@ -0,0 +1 @@
+<h1><?= htmlentities($user->displayName) ?></h1>