diff options
Diffstat (limited to 'src/application')
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&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"> +    © <?= 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&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&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&attachment=<?= htmlentities(urlencode($attachment->id)) ?>" title="<?= htmlentities($attachment->name) ?>"> +                            <img class="image-attachment-image" src="?_action=thumb&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&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&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> 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&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> |