This commit is contained in:
Zaira 2024-09-08 13:48:26 +03:00
commit 2f27bc593c
157 changed files with 15898 additions and 0 deletions

11
.env.dist Normal file
View File

@ -0,0 +1,11 @@
APP_ENV=development
APP_NAME=learning
POSTGRES_DB=forum
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
ADMIN_NAME=
ADMIN_PASSWORD=

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
vendor
storage/postgres-data
.env
docker-compose.yml

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Access\Controllers;
use App\Access\Models\AccessChecker\Access\AccessChecker;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\ModerateCategory;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\SharedKernel\Http\Validation;
use App\User\Models\UserRepository;
final class AssignCategoryController extends \Phalcon\Mvc\Controller
{
public function mainAction(string $userId): void
{
if (!$this->getAccessChecker()->canManageAccesses()) {
throw new Forbidden();
}
$user = $this->getUserRepository()->get($userId);
if ($this->request->isPost()) {
$validation = new Validation([
'category_id' => 'required',
]);
$validation->validate($_POST);
ModerateCategory::add($_POST['category_id'], $userId);
$this->response->redirect('/access/users/' . $userId . '/moderate-categories');
return;
}
echo $this->view->render(
__DIR__ . '/../Views/assign-category',
[
'user1' => $user,
'categories' => $this->getCategoryRepository()->findAll(),
]
);
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
private function getUserRepository(): UserRepository
{
return new UserRepository();
}
private function getAccessChecker(): AccessChecker
{
return new AccessChecker();
}
private function getAuth(): Auth
{
return new Auth();
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Access\Controllers;
use App\Access\Models\AccessChecker\Access\AccessChecker;
use App\Access\Models\Forbidden;
use App\Access\Models\Role;
use App\Auth\Models\Auth;
use App\SharedKernel\Http\Validation;
use App\User\Models\UserRepository;
final class AssignRoleController extends \Phalcon\Mvc\Controller
{
public function mainAction(string $userId): void
{
if (!$this->getAccessChecker()->canManageAccesses()) {
throw new Forbidden();
}
$user = $this->getUserRepository()->get($userId);
if ($this->request->isPost()) {
$validation = new Validation([
'role' => 'required',
]);
$validation->validate($_POST);
$initiator = $this->getAuth()->getUserFromSession();
$user->assignRole(Role::fromValue($_POST['role']), $initiator->id);
$this->response->redirect('/users/' . $userId);
return;
}
echo $this->view->render(
__DIR__ . '/../Views/assign-role',
['user1' => $user]
);
}
private function getUserRepository(): UserRepository
{
return new UserRepository();
}
private function getAccessChecker(): AccessChecker
{
return new AccessChecker();
}
private function getAuth(): Auth
{
return new Auth();
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Access\Controllers;
use App\Access\Models\AccessChecker\Access\AccessChecker;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\ModerateCategoryRepository;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\User\Models\UserRepository;
final class DeleteModerateCategoryController extends \Phalcon\Mvc\Controller
{
public function mainAction(string $userId, string $categoryId): void
{
if (!$this->getAccessChecker()->canManageAccesses()) {
throw new Forbidden();
}
$moderateCategory = $this->getModerateCategoryRepository()->getByUserIdAndCategoryId($userId, $categoryId);
$moderateCategory->delete();
$this->response->redirect('/access/users/' . $userId . '/moderate-categories');
}
private function getModerateCategoryRepository(): ModerateCategoryRepository
{
return new ModerateCategoryRepository();
}
private function getUserRepository(): UserRepository
{
return new UserRepository();
}
private function getAccessChecker(): AccessChecker
{
return new AccessChecker();
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Access\Controllers;
use App\Access\Models\AccessChecker\Access\AccessChecker;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\ModerateCategoryRepository;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\SharedKernel\Http\Validation;
use App\User\Models\UserRepository;
final class ModerateCategoriesController extends \Phalcon\Mvc\Controller
{
public function mainAction(string $userId): void
{
if (!$this->getAccessChecker()->canManageAccesses()) {
throw new Forbidden();
}
$user = $this->getUserRepository()->get($userId);
echo $this->view->render(
__DIR__ . '/../Views/moderate-categories',
[
'user1' => $user,
'categories' => $this->getModerateCategoryRepository()->findByUserId($userId),
]
);
}
private function getModerateCategoryRepository(): ModerateCategoryRepository
{
return new ModerateCategoryRepository();
}
private function getUserRepository(): UserRepository
{
return new UserRepository();
}
private function getAccessChecker(): AccessChecker
{
return new AccessChecker();
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Access;
use App\Access\Models\Role;
use App\Auth\Models\Auth;
final class AccessChecker
{
private $auth;
public function __construct()
{
$this->auth = new Auth();
}
public function canManageAccesses(): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
return $user->role == Role::admin()->value;
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Forum;
use App\Access\Models\Role;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\SessionStorage\CategoryIdsGetter;
use App\Auth\Models\Auth;
use Ramsey\Uuid\UuidInterface;
final class CategoryAccessChecker
{
private $auth;
private $categoryIdsGetter;
public function __construct()
{
$this->auth = new Auth();
$this->categoryIdsGetter = new CategoryIdsGetter();
}
public function canAdd(): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
if ($user->role === Role::admin()->value) {
return true;
}
return false;
}
public function canDelete(): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
if ($user->role === Role::admin()->value) {
return true;
}
return false;
}
public function canChange($categoryId): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
if ($user->role === Role::admin()->value) {
return true;
}
if ($user->role === Role::moderator()->value) {
return in_array($categoryId, $this->categoryIdsGetter->exec());
}
return false;
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Forum;
use App\Access\Models\Role;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\SessionStorage\CategoryIdsGetter;
use App\Auth\Models\Auth;
use Ramsey\Uuid\UuidInterface;
final class CommentAccessChecker
{
private $auth;
private $categoryIdsGetter;
public function __construct()
{
$this->auth = new Auth();
$this->categoryIdsGetter = new CategoryIdsGetter();
}
public function canAdd(): bool
{
return true;
}
public function canDelete($categoryId, $authorId): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
if ($authorId === $user->id) {
return true;
}
if ($user->role === Role::admin()->value) {
return true;
}
if ($user->role === Role::moderator()->value) {
return in_array($categoryId, $this->categoryIdsGetter->exec());
}
return false;
}
public function canChange($categoryId, $authorId): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
if ($authorId === $user->id) {
return true;
}
if ($user->role === Role::admin()->value) {
return true;
}
if ($user->role === Role::moderator()->value) {
return in_array($categoryId, $this->categoryIdsGetter->exec());
}
return false;
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Forum\ModerateCategory;
use App\Forum\Category\Models\Category;
use App\Forum\Category\Models\CategoryWriteRepository;
final class ModerateCategory extends \Phalcon\Mvc\Model
{
public function initialize(): void
{
$this->setSource('access_forum_moderate_categories');
}
public static function add($categoryId, $userId): void
{
$moderateCategory = new self([
'user_id' => $userId,
'category_id' => $categoryId,
]);
$moderateCategory->save();
}
public function getCategory(): Category
{
return (new CategoryWriteRepository())->get($this->category_id);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Forum\ModerateCategory;
use Phalcon\Mvc\Model\Resultset;
final class ModerateCategoryRepository
{
public function findByUserId($userId): Resultset
{
return ModerateCategory::find("user_id = '$userId'");
}
public function getByUserIdAndCategoryId($userId, $categoryId): ModerateCategory
{
return ModerateCategory::findFirst("user_id = '$userId' and category_id = '$categoryId'");
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Forum\ModerateCategory\SessionStorage;
final class CategoryIdsDestroyer
{
public function exec(): void
{
$this->getSession()->destroy('moderate_categories');
}
private function getSession()
{
return \Phalcon\DI::getDefault()->getShared('session');
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Forum\ModerateCategory\SessionStorage;
final class CategoryIdsGetter
{
public function exec(): array
{
return $this->getSession()->get('moderate_categories', []);
}
private function getSession()
{
return \Phalcon\DI::getDefault()->getShared('session');
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Forum\ModerateCategory\SessionStorage;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\ModerateCategoryRepository;
use Ramsey\Uuid\UuidInterface;
final class CategoryIdsSetter
{
private $moderateCategoryRepository;
public function __construct()
{
$this->moderateCategoryRepository = new ModerateCategoryRepository();
}
public function exec(UuidInterface $userId): void
{
$session = $this->getSession();
$session->set('moderate_categories', []);
$moderateCategories = $this->moderateCategoryRepository->findByUserId($userId);
foreach ($moderateCategories as $moderateCategory) {
$session->set(
'moderate_categories',
array_merge(
$session->get('moderate_categories'),
[$moderateCategory->category_id]
)
);
}
}
private function getSession()
{
return \Phalcon\DI::getDefault()->getShared('session');
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\Forum;
use App\Access\Models\Role;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\SessionStorage\CategoryIdsGetter;
use App\Auth\Models\Auth;
use Ramsey\Uuid\UuidInterface;
final class TopicAccessChecker
{
private $auth;
private $categoryIdsGetter;
public function __construct()
{
$this->auth = new Auth();
$this->categoryIdsGetter = new CategoryIdsGetter();
}
public function canAdd(): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
return true;
}
public function canDelete($categoryId, $authorId): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
if ($authorId === $user->id) {
return true;
}
if ($user->role === Role::admin()->value) {
return true;
}
if ($user->role === Role::moderator()->value) {
return in_array($categoryId, $this->categoryIdsGetter->exec());
}
return false;
}
public function canChange($categoryId, $authorId): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
if ($authorId === $user->id) {
return true;
}
if ($user->role === Role::admin()->value) {
return true;
}
if ($user->role === Role::moderator()->value) {
return in_array($categoryId, $this->categoryIdsGetter->exec());
}
return false;
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Access\Models\AccessChecker\User;
use App\Access\Models\Role;
use App\Auth\Models\Auth;
final class AccessChecker
{
private $auth;
public function __construct()
{
$this->auth = new Auth();
}
public function canManageUsers(): bool
{
$user = $this->auth->getUserFromSession();
if ($user === null) {
return false;
}
return $user->role === Role::admin()->value;
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Access\Models;
final class Forbidden extends \Exception
{
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Access\Models;
use App\SharedKernel\Structs\Enum;
final class Role extends Enum
{
public static function user(): self
{
return new self('user');
}
public static function moderator(): self
{
return new self('moderator');
}
public static function admin(): self
{
return new self('admin');
}
}

View File

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/pages/assign-category.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<a class="margin-right-8 text-gray" href="/">Topics</a>
{% if userAccess.canManageUsers() %}
<span class="margin-right-8 text-primary">Users</span>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Assign category</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/users">Users</a>
</li>
<li class="breadcrumbs-item">
<a href="/users/{{ user1.id }}">{{ user1.name }}</a>
</li>
<li class="breadcrumbs-item">
<a href="/access/users/{{ user1.id }}/moderate-categories">Moderate categories</a>
</li>
<li class="breadcrumbs-item active">Assign category</li>
</ol>
</nav>
<form action="/access/users/{{ user1.id }}/assign-category" method="post">
<div>
<select name="category_id" class="form-input">
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="form-button">Assign</button>
</form>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/pages/assign-role.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<a class="margin-right-8 text-gray" href="/">Topics</a>
{% if userAccess.canManageUsers() %}
<span class="margin-right-8 text-primary">Users</span>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Assign role</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/users">Users</a>
</li>
<li class="breadcrumbs-item">
<a href="/users/{{ user1.id }}">{{ user1.name }}</a>
</li>
<li class="breadcrumbs-item active">Assign role</li>
</ol>
</nav>
<form action="/access/users/{{ user1.id }}/assign-role" method="post">
<div>
<select name="role" class="form-input">
<option value="user" {% if user1.role == 'user' %} selected {% endif %}>User</option>
<option value="moderator" {% if user1.role == 'moderator' %} selected {% endif %}>Moderator</option>
<option value="admin" {% if user1.role == 'admin' %} selected {% endif %}>Admin</option>
</select>
</div>
<button type="submit" class="form-button">Assign role</button>
</form>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/append-button.css">
<link rel="stylesheet" href="/assets/css/clickable-list.css">
<link rel="stylesheet" href="/assets/css/pages/moderate-categories.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<a class="margin-right-8 text-gray" href="/">Topics</a>
{% if userAccess.canManageUsers() %}
<span class="margin-right-8 text-primary">Users</span>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Moderate categories</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/users">Users</a>
</li>
<li class="breadcrumbs-item">
<a href="/users/{{ user1.id }}">{{ user1.name }}</a>
</li>
<li class="breadcrumbs-item active">Moderate categories</li>
</ol>
</nav>
<a class="append-button margin-bottom-16" href="/access/users/{{ user1.id }}/assign-category">
<div>Assign category</div>
<span class="icon material-icons">add</span>
</a>
<div class="clickable-list">
{% for category in categories %}
<div class="clickable-list-item">
<div>
<div>{{ category.getCategory().name }}</div>
</div>
<a href="/access/users/{{ user1.id }}/moderate-categories/{{ category.category_id }}/delete" class="icon material-icons">delete</a>
</div>
{% endfor %}
</div>
</div>
</main>
</body>
</html>

42
app/Access/routes.php Normal file
View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
return [
'access:moderate-categories' => [
'pattern' => '/access/users/{userId}/moderate-categories',
'paths' => [
'namespace' => 'App\Access\Controllers',
'controller' => 'moderate-categories',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
'access:delete-moderate-category' => [
'pattern' => '/access/users/{userId}/moderate-categories/{categoryId}/delete',
'paths' => [
'namespace' => 'App\Access\Controllers',
'controller' => 'delete-moderate-category',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
'access:assign-category' => [
'pattern' => '/access/users/{userId}/assign-category',
'paths' => [
'namespace' => 'App\Access\Controllers',
'controller' => 'assign-category',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
'access:assign-role' => [
'pattern' => '/access/users/{userId}/assign-role',
'paths' => [
'namespace' => 'App\Access\Controllers',
'controller' => 'assign-role',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
];

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Auth\Controllers;
use App\Auth\Models\Auth;
use App\Auth\Models\LoginFailed;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\Controllers\Validation;
final class LoginController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
use Validation;
public function execAction(): void
{
if ($this->request->isPost()) {
try {
$this->validatePostRequest([
'name' => 'required',
'password' => 'required'
]);
$this->getAuth()->login($_POST['name'], $_POST['password']);
$this->response->redirect('/');
return;
} catch (\InvalidArgumentException|LoginFailed $exception) {
$this->renderView(['error' => $exception->getMessage()]);
return;
}
}
$this->renderView();
}
private function getAuth(): Auth
{
return new Auth();
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Auth\Controllers;
use App\Auth\Models\Auth;
final class LogoutController extends \Phalcon\Mvc\Controller
{
public function execAction(): void
{
$this->getAuth()->logout();
$this->response->redirect('/');
}
private function getAuth(): Auth
{
return new Auth();
}
}

73
app/Auth/Models/Auth.php Normal file
View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Auth\Models;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\SessionStorage\CategoryIdsDestroyer;
use App\Access\Models\AccessChecker\Forum\ModerateCategory\SessionStorage\CategoryIdsSetter;
use App\User\Models\User;
use App\User\Models\UserNotFound;
use App\User\Models\UserRepository;
use Ramsey\Uuid\Uuid;
final class Auth
{
private $userRepository;
private $accessSessionSetter;
private $accessSessionDestroyer;
public function __construct()
{
$this->userRepository = new UserRepository();
$this->accessSessionSetter = new CategoryIdsSetter();
$this->accessSessionDestroyer = new CategoryIdsDestroyer();
}
/**
* @return LoginFailed
*/
public function login(string $name, string $password): void
{
try {
$user = $this->userRepository->getByName($name);
} catch (UserNotFound $exception) {
throw new LoginFailed();
}
if ($user->password_hash === hash('sha256', $password)) {
$this->getSession()->set('user_id', $user->id);
$this->accessSessionSetter->exec(Uuid::fromString($user->id));
return;
}
throw new LoginFailed();
}
public function getUserFromSession(): ?User
{
if (!$this->getSession()->has('user_id')) {
return null;
}
try {
$userId = Uuid::fromString($this->getSession()->get('user_id'));
return $this->userRepository->get($userId);
} catch (UserNotFound $exception) {
return null;
}
}
public function logout(): void
{
// $this->accessSessionDestroyer->exec();
$this->getSession()->destroy();
}
private function getSession()
{
return \Phalcon\DI::getDefault()->getShared('session');
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Auth\Models;
final class IsNotAuthenticated extends \Exception
{
public function __construct()
{
parent::__construct('Authentication required');
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Auth\Models;
final class LoginFailed extends \Exception
{
public function __construct()
{
parent::__construct('Username or password isn\'t correct');
}
}

66
app/Auth/Views/login.volt Normal file
View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/pages/login.css">
</head>
<body>
<main class="full-height">
<div class="page-box content-centered min-height-380">
<div class="width-100 max-width-280 margin-bottom-8">
<div class="header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="header-menu">
<div>
<a class="margin-right-8 text-gray" href="/">Topics</a>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">USERS</a>
{% endif %}
<span class="text-primary" href="/login">LOGIN</span>
</div>
</div>
</div>
<h1 class="title width-100 max-width-280 margin-bottom-32">Login</h1>
{% if error is not null %}
<div class="error max-width-280" style="color: #000000;
width: 100%;
margin-top: -24px;height: auto;
margin-bottom: 8px;">
{{ error }}
</div>
{% endif %}
<form method="post" class="width-100 max-width-280">
<div>
<input class="form-input margin-bottom-16" name="name" placeholder="username" required />
</div>
<div>
<input class="form-input margin-bottom-16" name="password" type="password" placeholder="password" required />
</div>
<button class="form-button width-100" type="submit">Login</button>
</form>
</div>
</main>
</body>
</html>

24
app/Auth/routes.php Normal file
View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'auth:login' => [
'pattern' => '/login',
'paths' => [
'namespace' => 'App\Auth\Controllers',
'controller' => 'login',
'action' => 'exec',
],
'httpMethods' => ['GET', 'POST'],
],
'auth:logout' => [
'pattern' => '/logout',
'paths' => [
'namespace' => 'App\Auth\Controllers',
'controller' => 'logout',
'action' => 'exec',
],
'httpMethods' => ['GET'],
],
];

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Controllers;
use App\Access\Models\AccessChecker\Forum\CategoryAccessChecker;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\Forum\Category\Models\Category;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\Controllers\Validation;
use Ramsey\Uuid\Uuid;
final class AddController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
use Validation;
public function mainAction()
{
if (!$this->getCategoryAccessChecker()->canAdd()) {
throw new Forbidden();
}
if ($this->request->isPost()) {
try {
$this->validatePostRequest(['name' => 'required|length_between:1,64']);
$user = $this->getAuth()->getUserFromSession();
$category = Category::add(
Uuid::uuid4(),
$_POST['name'],
$user->id
);
$this->response->redirect('/' . $category->slug);
return;
} catch (\LogicException $e) {
$this->renderView(['error' => $e->getMessage(), 'name' => $_POST['name']]);
return;
}
}
$this->renderView();
}
private function getCategoryAccessChecker(): CategoryAccessChecker
{
return new CategoryAccessChecker();
}
private function getAuth(): Auth
{
return new Auth();
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Controllers;
use App\Access\Models\AccessChecker\Forum\CategoryAccessChecker;
use App\Access\Models\Forbidden;
use App\Forum\Category\Models\CategoryWriteRepository;
use Ramsey\Uuid\Uuid;
final class DeleteController extends \Phalcon\Mvc\Controller
{
public function mainAction(string $id): void
{
if (!$this->getCategoryAccessChecker()->canDelete()) {
throw new Forbidden();
}
$category = $this->getCategoryRepository()->get(Uuid::fromString($id));
$category->delete();
$this->response->redirect('/');
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
private function getCategoryAccessChecker(): CategoryAccessChecker
{
return new CategoryAccessChecker();
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Controllers;
use App\Access\Models\AccessChecker\Forum\CategoryAccessChecker;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\Controllers\Validation;
final class EditController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
use Validation;
public function mainAction(string $id)
{
if (!$this->getCategoryAccessChecker()->canChange($id)) {
throw new Forbidden();
}
$category = $this->getCategoryRepository()->get($id);
if ($this->request->isPost()) {
$this->validatePostRequest(['name' => 'required|length_between:1,64']);
$user = $this->getAuth()->getUserFromSession();
$category->edit($_POST['name'], $user->id);
$this->response->redirect('/' . $category->slug);
return;
}
$this->renderView(['category' => $category]);
}
private function getCategoryAccessChecker(): CategoryAccessChecker
{
return new CategoryAccessChecker();
}
private function getAuth(): Auth
{
return new Auth();
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Controllers;
use App\Forum\Category\Models\CategoryReadRepository;
use App\Forum\Topic\Models\TopicReadRepository;
use App\SharedKernel\Controllers\ModuleViewRender;
final class IndexController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
public function mainAction(): void
{
$topicsPerCategory = 5;
$categories = $this->getCategoryReadRepository()->findNotEmptyOrderedByLastActivity();
foreach ($categories as &$category) {
$category['last_topics'] = $this
->getTopicReadRepository()
->findByCategoryIdOrderedByLastActivity($category['id'], $topicsPerCategory);
}
$this->renderView(['categories' => $categories, 'topicsPerCategory' => $topicsPerCategory]);
}
private function getCategoryReadRepository(): CategoryReadRepository
{
return new CategoryReadRepository();
}
private function getTopicReadRepository(): TopicReadRepository
{
return new TopicReadRepository();
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Controllers;
use App\Forum\Category\Models\CategoryReadRepository;
use App\Forum\Topic\Models\TopicReadRepository;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\Controllers\Pagination;
final class ShowController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
use Pagination;
public function mainAction(string $slug): void
{
$rowsPerPage = 10;
$page = $this->getCurrentPage();
$category = $this->getCategoryReadRepository()->getBySlug($slug);
$topics = $this
->getTopicReadRepository()
->findByCategoryIdOrderedByLastActivity(
$category['id'],
$rowsPerPage,
$this->getSkipRowsNumber($rowsPerPage)
);
$topicsCount = $this->getTopicReadRepository()->countByCategoryId($category['id']);
$this->renderView([
'category' => $category,
'topics' => $topics,
'page' => $page,
'pages' => $this->getTotalPages($topicsCount, $rowsPerPage),
]);
}
private function getCategoryReadRepository(): CategoryReadRepository
{
return new CategoryReadRepository();
}
private function getTopicReadRepository(): TopicReadRepository
{
return new TopicReadRepository();
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Models;
use App\Forum\Topic\Models\TopicWriteRepository;
use Phalcon\Mvc\Model\Resultset;
final class Category extends \Phalcon\Mvc\Model
{
public function initialize(): void
{
$this->setSource('forum_categories');
}
public static function add($id, string $name, $userId): self
{
if ((new CategoryReadRepository())->existByName(ucfirst($name))) {
throw new CategoryAlreadyExist();
}
$category = new self([
'id' => $id,
'name' => ucfirst($name),
'slug' => UniqueSlugGenerator::generate($name),
'created_at' => (new \DateTime('now'))->format('Y-m-d H:i:s'),
'created_by' => $userId,
]);
$category->save();
return $category;
}
public function edit(string $name, $userId): void
{
$this->name = ucfirst($name);
$this->slug = UniqueSlugGenerator::generate($name);
$this->updated_at = (new \DateTime('now'))->format('Y-m-d H:i:s');
$this->updated_by = $userId;
$this->save();
}
public function delete(): void
{
foreach ((new TopicWriteRepository())->findByCategoryId($this->id) as $topic) {
$topic->delete();
}
parent::delete();
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Models;
final class CategoryAlreadyExist extends \DomainException
{
public function __construct()
{
parent::__construct('Category with same name already exist');
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Models;
use App\SharedKernel\Exceptions\NotFoundException;
final class CategoryNotFound extends NotFoundException
{
public function __construct()
{
parent::__construct('Category not found');
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Models;
final class CategoryReadRepository
{
public function existByName(string $name): bool
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$query = $connection->query("SELECT count(id) FROM forum_categories WHERE name = '$name'");
return 0 !== (int) $query->fetch()[0];
}
public function existBySlug(string $slug): bool
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$query = $connection->query("SELECT count(id) FROM forum_categories WHERE slug = '$slug'");
return 0 !== (int) $query->fetch()[0];
}
public function getBySlug(string $slug): array
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$sql = "SELECT id, name, slug FROM forum_categories WHERE slug = '$slug'";
$query = $connection->query($sql);
$query->setFetchMode(\Phalcon\Db::FETCH_ASSOC);
$result = $query->fetch();
if ($result === false) {
throw new CategoryNotFound();
}
return $result;
}
public function findNotEmptyOrderedByLastActivity(): array
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$sql = <<<SQL
SELECT id, name, slug, topics_count.topics_count
FROM forum_categories
LEFT JOIN (
SELECT category_id, max(created_at) as created_at
FROM forum_topics
GROUP BY 1
) last_topic ON last_topic.category_id = forum_categories.id
LEFT JOIN (
SELECT ft.category_id, max(fc.created_at) as created_at
FROM forum_comments fc
INNER JOIN forum_topics ft ON ft.id = fc.topic_id
GROUP BY 1
) last_comment ON last_comment.category_id = forum_categories.id
LEFT JOIN (
SELECT category_id, count(id) as topics_count
FROM forum_topics
GROUP BY 1
) topics_count ON topics_count.category_id = forum_categories.id
ORDER BY last_topic.created_at DESC NULLS LAST, last_comment.created_at DESC NULLS LAST
SQL;
$query = $connection->query($sql);
$query->setFetchMode(\Phalcon\Db::FETCH_ASSOC);
return $query->fetchAll();
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Models;
use Phalcon\Mvc\Model\Resultset;
final class CategoryWriteRepository
{
public function get($id): Category
{
$category = Category::findFirst("id = '$id'");
if ($category === false) {
throw new CategoryNotFound();
}
return $category;
}
public function getBySlug(string $slug): Category
{
$category = Category::findFirst("slug = '$slug'");
if ($category === false) {
throw new CategoryNotFound();
}
return $category;
}
public function findAll(): Resultset
{
return Category::find();
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Forum\Category\Models;
use App\SharedKernel\StringConverter;
final class UniqueSlugGenerator
{
public static function generate(string $name): string
{
$config = include __DIR__ . '/../config.php';
$categoryReadRepository = new CategoryReadRepository();
$number = 0;
while (true) {
$slug = StringConverter::readableToSlug($name) . ($number === 0 ? '' : '-' . $number);
if (!in_array($slug, $config['excluding_slugs']) && !$categoryReadRepository->existBySlug($slug)) {
return $slug;
}
$number++;
}
}
}

View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/pages/add-category.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Add category</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/">All categories</a>
</li>
<li class="breadcrumbs-item active">New</li>
</ol>
</nav>
{% if error is not null %}
<div class="error margin-bottom-16" style="color: #000000;width: 100%;">
{{ error }}
</div>
{% endif %}
<form action="/add-category" method="post">
<div>
<input
class="form-input"
name="name"
placeholder="Name of the category"
{% if name is not null %} value="{{ name }}" {% endif %}
required
/>
</div>
<button class="form-button" type="submit">Add category</button>
</form>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/append-button.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/pages/add-category.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Edit category</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/">All categories</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category.slug }}">{{ category.name }}</a>
</li>
<li class="breadcrumbs-item active">Edit</li>
</ol>
</nav>
<form action="/categories/{{ category.id }}" method="post">
<div>
<input class="form-input" name="name" placeholder="Name of the category" value="{{ category.name }}" />
</div>
<div style="display: flex;justify-content: space-between;">
<button class="form-button" type="submit">Update</button>
{% if categoryAccess.canDelete() %}
<a class="form-button" href="/categories/{{ category.id }}/delete">
<span class="icon material-icons" style="margin-right: 4px">delete</span>
Delete category
</a>
{% endif %}
</div>
</form>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/append-button.css">
<link rel="stylesheet" href="/assets/css/clickable-list.css">
<link rel="stylesheet" href="/assets/css/pagination.css">
<link rel="stylesheet" href="/assets/css/pages/categories.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">All Categories</h1>
{% if categoryAccess.canAdd() %}
<a class="append-button" href="/add-category">
<div>Add category</div>
<span class="icon material-icons">add</span>
</a>
{% endif %}
{% for category in categories %}
{% if user is null and category['topics_count'] is 0 %}
{% continue %}
{% endif %}
<div class="secondary-title">
<h2>
<a href="/{{ category['slug'] }}">{{ category['name'] }}</a>
</h2>
<a class="secondary-title-action" href="/{{ category['slug'] }}">
{% if category['topics_count'] > topicsPerCategory %}
See all {{ category['topics_count']}} topics
{% endif %}
</a>
</div>
{% if category['topics_count'] is 0 %}
<div style="font-size: 14px;color: #93989f;">No topics yet</div>
{% endif %}
<div class="clickable-list">
{% for topic in category['last_topics'] %}
<a class="clickable-list-item" href="/{{ category['slug'] }}/{{ topic['slug'] }}">
<div>
<div>{{ topic['name'] }}</div>
<div class="clickable-list-item-sub">
<span class="icon material-icons-outlined">mode_comment</span>
<span>{{ topic['comments_count'] == 0 ? 'no comments yet' : topic['comments_count'] }}</span>
</div>
</div>
<span class="icon material-icons">keyboard_arrow_right</span>
</a>
{% endfor %}
</div>
{% endfor %}
</div>
</main>
<br/>
</body>
</html>

View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/content.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/append-button.css">
<link rel="stylesheet" href="/assets/css/clickable-list.css">
<link rel="stylesheet" href="/assets/css/pagination.css">
<link rel="stylesheet" href="/assets/css/pages/category.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">
<span>{{ category['name'] }}</span>
{% if categoryAccess.canChange(category['id']) %}
<a href="/categories/{{ category['id'] }}" class="icon material-icons">edit</a>
{% endif %}
</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/">All categories</a>
</li>
<li class="breadcrumbs-item active">{{ category['name'] }}</li>
</ol>
</nav>
{% if topicAccess.canAdd() %}
<a class="append-button margin-bottom-16" href="/{{ category['slug'] }}/add-topic">
<div>Add topic</div>
<span class="icon material-icons">add</span>
</a>
{% endif %}
<div class="clickable-list">
{% for topic in topics %}
<a class="clickable-list-item" href="/{{ category['slug'] }}/{{ topic['slug'] }}">
<div>
<div>{{ topic['name'] }}</div>
<div class="clickable-list-item-sub">
<span class="icon material-icons-outlined">mode_comment</span>
<span>{{ topic['comments_count'] == 0 ? 'no comments yet' : topic['comments_count'] }}</span>
</div>
</div>
<span class="icon material-icons">keyboard_arrow_right</span>
</a>
{% endfor %}
</div>
{% if pages > 1 %}
<div class="pagination">
<span>{{ page }} of {{ pages }} pages</span>
<div>
{% if page > 2 %}
<a class="pagination-action" href="/{{ category['slug'] }}?page=1">First page</a>
{% endif %}
{% if page > 1 %}
<a class="pagination-action" href="/{{ category['slug'] }}?page={{ page - 1 }}">Back</a>
{% endif %}
{% if page < pages %}
<a class="pagination-action" href="/{{ category['slug'] }}?page={{ page + 1 }}">Next</a>
{% endif %}
{% if page < pages and (pages - page) > 1 %}
<a class="pagination-action" href="/{{ category['slug'] }}?page={{ pages }}">Last page</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</main>
<br/>
</body>
</html>

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
return [
'excluding_slugs' => [
'access',
'login',
'logout',
'categories',
'add-category',
'comments',
'topics',
'users',
'add-user',
],
];

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
return [
'category:delete' => [
'pattern' => '/categories/{id}/delete',
'paths' => [
'namespace' => 'App\Forum\Category\Controllers',
'controller' => 'delete',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
'category:edit' => [
'pattern' => '/categories/{id}',
'paths' => [
'namespace' => 'App\Forum\Category\Controllers',
'controller' => 'edit',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
'category:show' => [
'pattern' => '/{slug}',
'paths' => [
'namespace' => 'App\Forum\Category\Controllers',
'controller' => 'show',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
'category:index' => [
'pattern' => '/',
'paths' => [
'namespace' => 'App\Forum\Category\Controllers',
'controller' => 'index',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
'category:add' => [
'pattern' => '/add-category',
'paths' => [
'namespace' => 'App\Forum\Category\Controllers',
'controller' => 'add',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
];

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Forum\Comment\Controllers;
use App\Access\Models\AccessChecker\Forum\CommentAccessChecker;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\Forum\Comment\Models\Comment;
use App\Forum\Comment\Models\CommentWriteRepository;
use App\Forum\Topic\Models\TopicWriteRepository;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\File\Models\File;
use App\SharedKernel\Http\RequestFilesNormalizer;
use App\SharedKernel\Http\Validation;
use Ramsey\Uuid\Uuid;
final class AddController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
public function mainAction(string $categorySlug, string $topicSlug): void
{
if (!$this->getCommentAccessChecker()->canAdd()) {
throw new Forbidden();
}
$category = $this->getCategoryRepository()->getBySlug($categorySlug);
$topic = $this->getTopicRepository()->getByCategoryIdAndSlug(
Uuid::fromString($category->id),
$topicSlug
);
$replyToId = $this->request->getQuery('reply_to', 'string');
$replyToId = $replyToId !== '' ? $replyToId : null;
$replyTo = null;
if ($replyToId !== null) {
$replyTo = $this->getCommentRepository()->get($replyToId);
}
if ($this->request->isPost()) {
try {
$validation = new Validation([
'content' => 'required|length_between:1,2000',
]);
$validation->validate($_POST);
$images = [];
if ($_FILES['images']['error'][0] !== 4) {
$images = RequestFilesNormalizer::normalize($_FILES['images']);
$maxTotalSize = 1024 * 1024 * 4;
$totalSize = 0;
foreach ($images as $image) {
if (!File::isImageMimeType(File::extractMimeType($image['tmp_name']))) {
throw new \InvalidArgumentException('You can attach only images');
}
$totalSize = $image['size'];
}
if ($totalSize > $maxTotalSize) {
throw new \InvalidArgumentException('Total size of all images must be less than 4MB');
}
}
$user = $this->getAuth()->getUserFromSession();
$commentId = Uuid::uuid4();
Comment::add(
$commentId,
Uuid::fromString($topic->id),
$_POST['content'],
$user !== null ? $user->id : null,
isset($_POST['reply_to']) ? $_POST['reply_to'] : null
);
foreach ($images as $image) {
File::addForForumComment(
Uuid::uuid4(),
$commentId,
$image['tmp_name'],
$image['name']
);
}
$this->response->redirect("/$categorySlug/$topicSlug");
return;
} catch (\InvalidArgumentException $e) {
$this->renderView([
'category' => $category,
'topic' => $topic,
'replyTo' => $replyTo,
'error' => $e->getMessage(),
]);
return;
}
}
$this->renderView([
'category' => $category,
'topic' => $topic,
'replyTo' => $replyTo,
]);
}
private function getCommentRepository(): CommentWriteRepository
{
return new CommentWriteRepository();
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
private function getTopicRepository(): TopicWriteRepository
{
return new TopicWriteRepository();
}
private function getCommentAccessChecker(): CommentAccessChecker
{
return new CommentAccessChecker();
}
private function getAuth(): Auth
{
return new Auth();
}
}

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Forum\Comment\Controllers;
use App\Access\Models\AccessChecker\Forum\CommentAccessChecker;
use App\Access\Models\Forbidden;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\Forum\Comment\Models\CommentWriteRepository;
use App\Forum\Topic\Models\TopicWriteRepository;
final class DeleteController extends \Phalcon\Mvc\Controller
{
public function mainAction(string $id): void
{
$comment = $this->getCommentRepository()->get($id);
$topic = $this->getTopicRepository()->get($comment->topic_id);
$category = $this->getCategoryRepository()->get($topic->category_id);
if (!$this->getCommentAccessChecker()->canDelete($topic->category_id, $comment->created_by)) {
throw new Forbidden();
}
$this->getCommentRepository()->get($id)->delete();
$this->response->redirect('/' . $category->slug . '/' . $topic->slug);
}
private function getCommentAccessChecker(): CommentAccessChecker
{
return new CommentAccessChecker();
}
private function getTopicRepository(): TopicWriteRepository
{
return new TopicWriteRepository();
}
private function getCommentRepository(): CommentWriteRepository
{
return new CommentWriteRepository();
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
}

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Forum\Comment\Controllers;
use App\Access\Models\AccessChecker\Forum\CommentAccessChecker;
use App\Access\Models\AccessChecker\Forum\TopicAccessChecker;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\Forum\Comment\Models\CommentWriteRepository;
use App\Forum\Topic\Models\TopicWriteRepository;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\File\Models\File;
use App\SharedKernel\File\Models\FileRepository;
use App\SharedKernel\Http\RequestFilesNormalizer;
use App\SharedKernel\Http\Validation;
use Ramsey\Uuid\Uuid;
final class EditController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
public function mainAction(string $id): void
{
$comment = $this->getCommentRepository()->get($id);
$topic = $this->getTopicRepository()->get($comment->topic_id);
$category = $this->getCategoryRepository()->get($topic->category_id);
if (!$this->getCommentAccessChecker()->canChange($topic->category_id, $comment->created_by)) {
throw new Forbidden();
}
if ($this->request->isPost()) {
try {
$validation = new Validation([
'content' => 'required|length_between:1,2000',
]);
$validation->validate($_POST);
$images = [];
if ($_FILES['images']['error'][0] !== 4) {
$images = RequestFilesNormalizer::normalize($_FILES['images']);
$maxTotalSize = 1024 * 1024 * 4;
$totalSize = 0;
foreach ($images as $image) {
if (!File::isImageMimeType(File::extractMimeType($image['tmp_name']))) {
throw new \InvalidArgumentException('You can attach only images');
}
$totalSize = $image['size'];
}
if ($totalSize > $maxTotalSize) {
throw new \InvalidArgumentException('Total size of all images must be less than 4MB');
}
}
$user = $this->getAuth()->getUserFromSession();
$comment->edit($_POST['content'], $user->id);
foreach ($images as $image) {
File::addForForumComment(
Uuid::uuid4(),
$comment->id,
$image['tmp_name'],
$image['name']
);
}
$this->response->redirect('/' . $category->slug . '/' . $topic->slug);
return;
} catch (\InvalidArgumentException $e) {
$this->renderView([
'category' => $category,
'topic' => $topic,
'comment' => $comment,
'images' => $this->getFileRepository()->findByForumCommentId($comment->id),
'error' => $e->getMessage(),
'content' => $_POST['content']
]);
}
}
$this->renderView([
'category' => $category,
'topic' => $topic,
'comment' => $comment,
'images' => $this->getFileRepository()->findByForumCommentId($comment->id),
]);
}
private function getFileRepository(): FileRepository
{
return new FileRepository();
}
private function getAuth(): Auth
{
return new Auth();
}
private function getCommentAccessChecker(): CommentAccessChecker
{
return new CommentAccessChecker();
}
private function getCommentRepository(): CommentWriteRepository
{
return new CommentWriteRepository();
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
private function getTopicRepository(): TopicWriteRepository
{
return new TopicWriteRepository();
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Forum\Comment\Models;
use App\SharedKernel\File\Models\FileRepository;
use App\User\Models\UserNotFound;
use App\User\Models\UserRepository;
use Ramsey\Uuid\UuidInterface;
final class Comment extends \Phalcon\Mvc\Model
{
public function initialize(): void
{
$this->setSource('forum_comments');
}
public static function add(UuidInterface $id, UuidInterface $topicId, string $content, $userId = null, $replyTo = null): void
{
$comment = new self([
'id' => $id,
'topic_id' => $topicId,
'content' => $content,
'created_at' => (new \DateTime('now'))->format('Y-m-d H:i:s'),
'created_by' => $userId,
'reply_to' => $replyTo,
]);
$comment->save();
}
public function edit(string $content, $userId): void
{
$this->content = $content;
$this->updated_at = (new \DateTime('now'))->format('Y-m-d H:i:s');
$this->updated_by = $userId;
$this->save();
}
public function delete(): void
{
foreach ((new FileRepository())->findByForumCommentId($this->id) as $file) {
$file->delete();
}
parent::delete();
}
public function getAuthorName(): string
{
$userRepository = new UserRepository();
try {
return $userRepository->get($this->created_by)->name;
} catch (UserNotFound $e) {
return 'anonymous';
}
}
public function getReadableDate(): string
{
$date = new \DateTime($this->created_at);
return $date->format('H:i d.m.Y');
}
public function getReplyTo(): ?Comment
{
if ($this->reply_to === null) {
return null;
}
try {
return (new CommentWriteRepository())->get($this->reply_to);
} catch (CommentNotFound $e) {
return null;
}
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Forum\Comment\Models;
use App\SharedKernel\Exceptions\NotFoundException;
final class CommentNotFound extends NotFoundException
{
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Forum\Comment\Models;
final class CommentReadRepository
{
public function findByTopicId(string $topicId, int $count, int $skip = 0): array
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$sql = <<<SQL
SELECT
forum_comments.id,
forum_comments.content,
coalesce(users.name, 'anonymous') as author_name,
forum_comments.created_by as authour_id,
forum_comments.created_at,
forum_comments.reply_to as reply_to_id,
reply_to.content as reply_to_content
FROM forum_comments
LEFT JOIN forum_comments as reply_to ON reply_to.id = forum_comments.reply_to
LEFT JOIN users ON users.id = forum_comments.created_by
WHERE forum_comments.topic_id = '$topicId'
ORDER BY forum_comments.created_at DESC
LIMIT $count OFFSET $skip
SQL;
$query = $connection->query($sql);
$query->setFetchMode(\Phalcon\Db::FETCH_ASSOC);
return $query->fetchAll();
}
public function countByTopicId(string $topicId): int
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$query = $connection->query("SELECT count(id) FROM forum_comments WHERE topic_id = '$topicId'");
return (int) $query->fetch()[0];
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Forum\Comment\Models;
use App\SharedKernel\TimeSorting;
use Phalcon\Mvc\Model\Resultset;
use Ramsey\Uuid\UuidInterface;
final class CommentWriteRepository
{
public function get($id): Comment
{
$comment = Comment::findFirst("id = '$id'");
if ($comment === false) {
throw new CommentNotFound();
}
return $comment;
}
public function countByTopicId(UuidInterface $topicId): int
{
return Comment::count("topic_id = '$topicId'");
}
public function findByTopicId(UuidInterface $topicId, TimeSorting $timeSorting, int $page, int $limit): Resultset
{
$sortingDirection = $timeSorting == TimeSorting::newest() ? 'desc' : 'asc';
return Comment::find([
"topic_id = '$topicId'",
'order' => 'created_at ' . $sortingDirection,
'limit' => $limit,
'offset' => ($page - 1) * $limit,
]);
}
public function findAllByTopicId($topicId): Resultset
{
return Comment::find([
'conditions' => 'topic_id = :topic_id:',
'bind' => [
'topic_id' => $topicId,
],
]);
}
}

View File

@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/pages/add-category.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Add comment</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/">All categories</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category.slug }}">{{ category.name }}</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category.slug }}/{{ topic.slug }}">{{ topic.name }}</a>
</li>
<li class="breadcrumbs-item active">Add comment</li>
</ol>
</nav>
{% if replyTo is not null %}
<div style="padding-left: 4px;
border-left: 2px solid #6d757d;
margin-bottom: 24px;
margin-left: 4px;
color: #6d757d;
font-size: 14px;">
{{ replyTo.content }}
</div>
{% endif %}
{% if error is not null %}
<div class="error" style="color: #000000;
width: 100%;
margin-bottom: 24px;margin-top: -8px;">
{{ error }}
</div>
{% endif %}
<form action="/{{ category.slug }}/{{ topic.slug }}/add-comment" method="post" enctype="multipart/form-data">
{% if replyTo is not null %}
<input type="hidden" name="reply_to" value="{{ replyTo.id }}" />
{% endif %}
<div class="margin-bottom-16">
{% if content is not null %}
<textarea class="form-input" name="content" rows="3" placeholder="Type your comment here..." required>{{ content }}</textarea>
{% else %}
<textarea class="form-input" name="content" rows="3" placeholder="Type your comment here..." required></textarea>
{% endif %}
</div>
<div>
<input name="images[]" type="file" multiple />
</div>
<button class="form-button" type="submit">Add comment</button>
</form>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/clickable-list.css">
<link rel="stylesheet" href="/assets/css/pages/add-category.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Edit comment</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/">All categories</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category.slug }}">{{ category.name }}</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category.slug }}/{{ topic.slug }}">{{ topic.name }}</a>
</li>
<li class="breadcrumbs-item active">Edit comment</li>
</ol>
</nav>
{% if error is not null %}
<div class="error" style="color: #000000;
width: 100%;
margin-bottom: 24px;margin-top: -8px;">
{{ error }}
</div>
{% endif %}
<form action="/comments/{{ comment.id }}" method="post" enctype="multipart/form-data">
<div class="margin-bottom-16">
<textarea class="form-input" name="content" rows="3" placeholder="Content">{{ content is not null ? content : comment.content }}</textarea>
</div>
<div>
<input name="images[]" type="file" multiple />
</div>
<button class="form-button" type="submit">Update</button>
</form>
<div class="clickable-list margin-top-24 margin-bottom-32">
{% for image in images %}
<div class="clickable-list-item">
<div>
<a href="/images/{{ image.id }}" target="_blank">
<img style="max-width: 160px;max-height: 160px;" src="{{ image.getImageBase64Content() }}">
</a>
</div>
<a href="/files/{{ image.id }}/delete?redirect_to=/comments/{{ comment.id }}" class="icon material-icons">delete</a>
</div>
{% endfor %}
</div>
</div>
</main>
</body>
</html>

View File

@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
return [];

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
return [
'comment:add' => [
'pattern' => '/{categorySlug}/{topicSlug}/add-comment',
'paths' => [
'namespace' => 'App\Forum\Comment\Controllers',
'controller' => 'add',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
'comment:delete' => [
'pattern' => '/comments/{id}/delete',
'paths' => [
'namespace' => 'App\Forum\Comment\Controllers',
'controller' => 'delete',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
'comment:edit' => [
'pattern' => '/comments/{id}',
'paths' => [
'namespace' => 'App\Forum\Comment\Controllers',
'controller' => 'edit',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
];

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Controllers;
use App\Access\Models\AccessChecker\Forum\TopicAccessChecker;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\Forum\Topic\Models\Topic;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\Controllers\Validation;
use App\SharedKernel\File\Models\File;
use App\SharedKernel\Http\RequestFilesNormalizer;
use Ramsey\Uuid\Uuid;
final class AddController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
use Validation;
public function mainAction(string $categorySlug): void
{
if (!$this->getTopicAccessChecker()->canAdd()) {
throw new Forbidden();
}
$category = $this->getCategoryRepository()->getBySlug($categorySlug);
if ($this->request->isPost()) {
try {
$this->validatePostRequest([
'name' => 'required|length_between:1,64',
'content' => 'required|length_between:1,20000',
]);
$images = [];
if ($_FILES['images']['error'][0] !== 4) {
$images = RequestFilesNormalizer::normalize($_FILES['images']);
$maxTotalSize = 1024 * 1024 * 4;
$totalSize = 0;
foreach ($images as $image) {
if (!File::isImageMimeType(File::extractMimeType($image['tmp_name']))) {
throw new \InvalidArgumentException('You can attach only images');
}
$totalSize = $image['size'];
}
if ($totalSize > $maxTotalSize) {
throw new \InvalidArgumentException('Total size of all images must be less than 4MB');
}
}
$user = $this->getAuth()->getUserFromSession();
$topic = Topic::add(
Uuid::uuid4(),
Uuid::fromString($category->id),
$_POST['name'],
$_POST['content'],
Uuid::fromString($user->id)
);
foreach ($images as $image) {
File::addForForumTopic(
Uuid::uuid4(),
$topic->id,
$image['tmp_name'],
$image['name']
);
}
$this->response->redirect('/' . $categorySlug . '/' . $topic->slug);
return;
} catch (\InvalidArgumentException $e) {
$this->renderView([
'category' => $category,
'error' => $e->getMessage(),
'name' => $_POST['name'],
'content' => $_POST['content'],
]);
return;
}
}
$this->renderView(['category' => $category]);
}
private function getTopicAccessChecker(): TopicAccessChecker
{
return new TopicAccessChecker();
}
private function getAuth(): Auth
{
return new Auth();
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Controllers;
use App\Access\Models\AccessChecker\Forum\TopicAccessChecker;
use App\Access\Models\Forbidden;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\Forum\Topic\Models\TopicWriteRepository;
final class DeleteController extends \Phalcon\Mvc\Controller
{
public function mainAction(string $id): void
{
$topic = $this->getTopicRepository()->get($id);
$category = $this->getCategoryRepository()->get($topic->category_id);
if (!$this->getTopicAccessChecker()->canDelete($topic->category_id, $topic->created_by)) {
throw new Forbidden();
}
$topic = $this->getTopicRepository()->get($id);
$topic->delete();
$this->response->redirect('/' . $category->slug);
}
private function getTopicAccessChecker(): TopicAccessChecker
{
return new TopicAccessChecker();
}
private function getTopicRepository(): TopicWriteRepository
{
return new TopicWriteRepository();
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
}

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Controllers;
use App\Access\Models\AccessChecker\Forum\TopicAccessChecker;
use App\Access\Models\Forbidden;
use App\Auth\Models\Auth;
use App\Forum\Category\Models\CategoryWriteRepository;
use App\Forum\Topic\Models\TopicWriteRepository;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\Controllers\Validation;
use App\SharedKernel\File\Models\File;
use App\SharedKernel\File\Models\FileRepository;
use App\SharedKernel\Http\RequestFilesNormalizer;
use Ramsey\Uuid\Uuid;
final class EditController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
use Validation;
public function mainAction(string $id): void
{
$topic = $this->getTopicRepository()->get($id);
$category = $this->getCategoryRepository()->get($topic->category_id);
if (!$this->getTopicAccessChecker()->canChange($topic->category_id, $topic->created_by)) {
throw new Forbidden();
}
if ($this->request->isPost()) {
try {
$this->validatePostRequest([
'name' => 'required|length_between:1,64',
'content' => 'required|length_between:1,20000',
]);
$images = [];
if ($_FILES['images']['error'][0] !== 4) {
$images = RequestFilesNormalizer::normalize($_FILES['images']);
$maxTotalSize = 1024 * 1024 * 4;
$totalSize = 0;
foreach ($images as $image) {
if (!File::isImageMimeType(File::extractMimeType($image['tmp_name']))) {
throw new \InvalidArgumentException('You can attach only images');
}
$totalSize = $image['size'];
}
if ($totalSize > $maxTotalSize) {
throw new \InvalidArgumentException('Total size of all images must be less than 4MB');
}
}
$user = $this->getAuth()->getUserFromSession();
$topic->edit(
$_POST['name'],
$_POST['content'],
$user->id
);
foreach ($images as $image) {
File::addForForumTopic(
Uuid::uuid4(),
$topic->id,
$image['tmp_name'],
$image['name']
);
}
$this->response->redirect('/' . $category->slug . '/' . $topic->slug);
return;
} catch (\InvalidArgumentException $e) {
$this->renderView([
'category' => $category,
'topic' => $topic,
'images' => $this->getFileRepository()->findByForumTopicId($topic->id),
'name' => $_POST['name'],
'content' => $_POST['content'],
'error' => $e->getMessage(),
]);
return;
}
}
$this->renderView([
'category' => $category,
'topic' => $topic,
'images' => $this->getFileRepository()->findByForumTopicId($topic->id),
]);
}
private function getFileRepository(): FileRepository
{
return new FileRepository();
}
private function getTopicAccessChecker(): TopicAccessChecker
{
return new TopicAccessChecker();
}
private function getAuth(): Auth
{
return new Auth();
}
private function getCategoryRepository(): CategoryWriteRepository
{
return new CategoryWriteRepository();
}
private function getTopicRepository(): TopicWriteRepository
{
return new TopicWriteRepository();
}
}

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Controllers;
use App\Forum\Category\Models\CategoryReadRepository;
use App\Forum\Comment\Models\CommentReadRepository;
use App\Forum\Topic\Models\TopicReadRepository;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\File\Models\FileRepository;
final class ShowController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
public function mainAction(string $categorySlug, string $slug): void
{
$page = (int) $this->request->getQuery('page', 'int', 1);
$category = $this->getCategoryReadRepository()->getBySlug($categorySlug);
$topic = $this->getTopicReadRepository()->getByCategoryIdAndSlug($category['id'], $slug);
$comments = $this->getCommentReadRepository()
->findByTopicId(
$topic['id'],
10,
($page - 1) * 10
);
$commentIds = [];
foreach ($comments as $comment) {
$commentIds[] = $comment['id'];
}
if ($commentIds !== []) {
$viewableCommentImages = [];
$commentImages = $this->getFileRepository()->findByForumCommentsIds($commentIds);
foreach ($commentImages as $commentImage) {
$viewableCommentImages[$commentImage->relation_id][] = $commentImage->id;
}
}
$this->renderView([
'category' => $category,
'topic' => $topic,
'images' => $this->getFileRepository()->findByForumTopicId($topic['id']),
'comments' => $comments,
'commentImages' => $viewableCommentImages ?? [],
'categorySlug' => $categorySlug,
'topicSlug' => $slug,
'page' => $page,
'pages' => ceil($this->getCommentReadRepository()->countByTopicId($topic['id']) / 10),
]);
}
private function getCategoryReadRepository(): CategoryReadRepository
{
return new CategoryReadRepository();
}
private function getTopicReadRepository(): TopicReadRepository
{
return new TopicReadRepository();
}
private function getFileRepository(): FileRepository
{
return new FileRepository();
}
private function getCommentReadRepository(): CommentReadRepository
{
return new CommentReadRepository();
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Models;
use App\Forum\Comment\Models\CommentWriteRepository;
use App\SharedKernel\File\Models\FileRepository;
use App\SharedKernel\StringConverter;
use Ramsey\Uuid\UuidInterface;
final class Topic extends \Phalcon\Mvc\Model
{
public function initialize(): void
{
$this->setSource('forum_topics');
}
public static function add($id, $categoryId, string $name, string $content, $userId): self
{
$topic = new Topic([
'id' => $id,
'category_id' => $categoryId,
'name' => $name,
'slug' => UniqueSlugGenerator::generate($name),
'content' => $content,
'created_at' => (new \DateTime('now'))->format('Y-m-d H:i:s'),
'created_by' => $userId,
]);
$topic->save();
return $topic;
}
public function edit(string $name, string $content, $userId): void
{
$this->name = $name;
$this->slug = UniqueSlugGenerator::generate($name);
$this->content = $content;
$this->updated_at = (new \DateTime('now'))->format('Y-m-d H:i:s');
$this->updated_by = $userId;
$this->save();
}
public function delete(): void
{
foreach ((new CommentWriteRepository())->findAllByTopicId($this->id) as $comment) {
$comment->delete();
}
foreach ((new FileRepository())->findByForumTopicId($this->id) as $file) {
$file->delete();
}
parent::delete();
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Models;
use App\SharedKernel\Exceptions\NotFoundException;
final class TopicNotFound extends NotFoundException
{
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Models;
final class TopicReadRepository
{
public function existBySlug(string $slug): bool
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$query = $connection->query("SELECT count(id) FROM forum_topics WHERE slug = '$slug'");
return 0 !== (int) $query->fetch()[0];
}
public function findByCategoryIdOrderedByLastActivity(string $categoryId, int $count, int $skip = 0): array
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$sql = <<<SQL
SELECT
forum_topics.id,
forum_topics.name,
forum_topics.slug,
COALESCE(comments_count.comments_count, 0) as comments_count,
users.id as author_id,
users.name as author_name
FROM forum_topics
LEFT JOIN (
SELECT topic_id, max(created_at) as created_at
FROM forum_comments
GROUP BY 1
) last_comment ON last_comment.topic_id = forum_topics.id
LEFT JOIN (
SELECT topic_id, count(id) as comments_count
FROM forum_comments
GROUP BY 1
) comments_count ON comments_count.topic_id = forum_topics.id
LEFT JOIN users ON users.id = forum_topics.created_by
WHERE category_id = '$categoryId'
ORDER BY forum_topics.created_at DESC, last_comment.created_at DESC
LIMIT $count OFFSET $skip
SQL;
$query = $connection->query($sql);
$query->setFetchMode(\Phalcon\Db::FETCH_ASSOC);
return $query->fetchAll();
}
public function countByCategoryId(string $categoryId): int
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$query = $connection->query("SELECT count(id) FROM forum_topics WHERE category_id = '$categoryId'");
return (int) $query->fetch()[0];
}
public function getByCategoryIdAndSlug(string $categoryId, string $slug): array
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$sql = <<<SQL
SELECT
forum_topics.id,
forum_topics.category_id,
forum_topics.name,
forum_topics.slug,
forum_topics.content,
forum_topics.created_at,
users.id as author_id,
users.name as author_name,
comments_count.comments_count
FROM forum_topics
LEFT JOIN users ON users.id = forum_topics.created_by
LEFT JOIN (
SELECT topic_id, count(id) as comments_count
FROM forum_comments
GROUP BY 1
) comments_count ON comments_count.topic_id = forum_topics.id
WHERE category_id = '$categoryId' AND slug = '$slug'
SQL;
$query = $connection->query($sql);
$query->setFetchMode(\Phalcon\Db::FETCH_ASSOC);
return $query->fetch();
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Models;
use Phalcon\Mvc\Model\Resultset;
use Ramsey\Uuid\UuidInterface;
final class TopicWriteRepository
{
public function get($id): Topic
{
$topic = Topic::findFirst("id = '$id'");
if ($topic === false) {
throw new TopicNotFound();
}
return $topic;
}
public function getByCategoryIdAndSlug(UuidInterface $categoryId, string $slug): Topic
{
$topic = Topic::findFirst("category_id = '$categoryId' and slug = '$slug'");
if ($topic === false) {
throw new TopicNotFound();
}
return $topic;
}
public function findByCategoryId($categoryId): Resultset
{
return Topic::find([
'conditions' => 'category_id = :category_id:',
'bind' => [
'category_id' => $categoryId,
],
]);
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Forum\Topic\Models;
use App\SharedKernel\StringConverter;
final class UniqueSlugGenerator
{
public static function generate(string $name): string
{
$config = include __DIR__ . '/../config.php';
$categoryReadRepository = new TopicReadRepository();
$number = 0;
while (true) {
$slug = StringConverter::readableToSlug($name) . ($number === 0 ? '' : '-' . $number);
if (!in_array($slug, $config['excluding_slugs']) && !$categoryReadRepository->existBySlug($slug)) {
return $slug;
}
$number++;
}
}
}

View File

@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/pages/add-category.css">
<link rel="stylesheet" href="/assets/quill/quill.snow.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Add topic</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/">All categories</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category.slug }}">{{ category.name }}</a>
</li>
<li class="breadcrumbs-item active">New topic</li>
</ol>
</nav>
{% if error is not null %}
<div class="error" style="color: #000000;
width: 100%;
margin-bottom: 24px;margin-top: -8px;">
{{ error }}
</div>
{% endif %}
<form action="/{{ category.slug }}/add-topic" method="post" enctype="multipart/form-data">
<div class="margin-bottom-16">
<input class="form-input" name="name" placeholder="Name of the topic" {% if name is not null %} value="{{ name }}" {% endif %} />
</div>
<div id="editor" style="display: none;height: 375px;" class="margin-bottom-16"></div>
<div class="margin-bottom-16">
{% if content is not null %}
<textarea class="form-input" name="content" rows="9" placeholder="Content">{{ content }}</textarea>
{% else %}
<textarea class="form-input" name="content" rows="9" placeholder="Content"></textarea>
{% endif %}
</div>
<div>
<input type="file" name="images[]" multiple />
</div>
<button class="form-button" type="submit">Add topic</button>
</form>
</div>
</main>
<script src="/assets/quill/quill.js"></script>
<script>
var textarea = document.querySelector('textarea[name=content]');
textarea.style.display = 'none';
document.querySelector('#editor').style.display = 'block';
var quill = new Quill('#editor', {
modules: {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
['code-block']
]
},
placeholder: 'Content',
theme: 'snow'
});
var form = document.querySelector('form');
form.onsubmit = function() {
textarea.value = document.querySelector('.ql-editor').innerHTML;
}
</script>
</body>
</html>

View File

@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/clickable-list.css">
<link rel="stylesheet" href="/assets/css/pages/add-category.css">
<link rel="stylesheet" href="/assets/quill/quill.snow.css">
</head>
<body>
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<h1 class="title">Edit topic</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/">All categories</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category.slug }}">{{ category.name }}</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category.slug }}/{{ topic.slug }}">{{ topic.name }}</a>
</li>
<li class="breadcrumbs-item active">Edit</li>
</ol>
</nav>
{% if error is not null %}
<div class="error" style="color: #000000;
width: 100%;
margin-bottom: 24px;margin-top: -8px;">
{{ error }}
</div>
{% endif %}
<form action="/topics/{{ topic.id }}" method="post" enctype="multipart/form-data">
<div class="margin-bottom-16">
<input class="form-input" name="name" placeholder="Name of the topic" value="{{ name is not null ? name : topic.name }}" />
</div>
<div id="editor" style="display: none;height: 375px;" class="margin-bottom-16">
{{ content is not null ? content : topic.content }}
</div>
<div class="margin-bottom-16">
<textarea class="form-input" name="content" rows="9" placeholder="Content">{{ content is not null ? content : topic.content }}</textarea>
</div>
<div>
<input type="file" name="images[]" multiple />
</div>
<div style="display: flex; justify-content: space-between;">
<button class="form-button" type="submit">Update</button>
{% if topicAccess.canDelete(topic.category_id, topic.created_by) %}
<a href="/topics/{{ topic.id }}/delete" class="form-button">
<span class="icon material-icons" style="margin-right: 4px">delete</span>
Delete topic
</a>
{% endif %}
</div>
</form>
<div class="clickable-list margin-top-24 margin-bottom-32">
{% for image in images %}
<div class="clickable-list-item">
<div>
<a href="/images/{{ image.id }}" target="_blank">
<img style="max-width: 160px;max-height: 160px;" src="{{ image.getImageBase64Content() }}">
</a>
</div>
<a href="/files/{{ image.id }}/delete?redirect_to=/topics/{{ topic.id }}" class="icon material-icons">delete</a>
</div>
{% endfor %}
</div>
</div>
</main>
<script src="/assets/quill/quill.js"></script>
<script>
var textarea = document.querySelector('textarea[name=content]');
textarea.style.display = 'none';
document.querySelector('#editor').style.display = 'block';
var quill = new Quill('#editor', {
modules: {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
['code-block']
]
},
placeholder: 'Content',
theme: 'snow'
});
var form = document.querySelector('form');
form.onsubmit = function() {
textarea.value = document.querySelector('.ql-editor').innerHTML;
}
</script>
</body>
</html>

View File

@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/material-icons.css">
<link rel="stylesheet" href="/assets/css/shared.css">
<link rel="stylesheet" href="/assets/css/header.css">
<link rel="stylesheet" href="/assets/css/breadcrumbs.css">
<link rel="stylesheet" href="/assets/css/pagination.css">
<link rel="stylesheet" href="/assets/css/form.css">
<link rel="stylesheet" href="/assets/css/pages/topic.css">
</head>
<body>
<section class="main-section">
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<span class="margin-right-8 text-primary">Topics</span>
{% if userAccess.canManageUsers() %}
<a class="margin-right-8 text-gray" href="/users">Users</a>
{% endif %}
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>
<main>
<div class="page-box">
<div class="author">
<div>
<span class="icon material-icons-outlined">account_box</span>
<span>{{ topic['author_name'] }}</span>
</div>
</div>
<h1 class="title">
<span>{{ topic['name'] }}</span>
{% if topicAccess.canChange(topic['category_id'], topic['author_id']) %}
<a href="/topics/{{ topic['id'] }}" class="icon material-icons">edit</a>
{% endif %}
</h1>
<nav>
<ol class="breadcrumbs">
<li class="breadcrumbs-item">
<a href="/">All categories</a>
</li>
<li class="breadcrumbs-item">
<a href="/{{ category['slug'] }}">{{ category['name'] }}</a>
</li>
<li class="breadcrumbs-item active">{{ topic['name'] }}</li>
</ol>
</nav>
{% if images|length > 0 %}
<div class="margin-bottom-16">
{% for image in images %}
<a href="/images/{{ image.id }}" target="_blank">
<img style="max-width: 160px;max-height: 160px;" src="/images/{{ image.id }}">
</a>
{% endfor %}
</div>
{% endif %}
<div class="content">{{ topic['content'] }}</div>
</div>
</main>
{% if comments|length > 0 %}
<section class="comments-section">
<div class="page-box">
<div>
<div class="secondary-title">{{ topic['comments_count'] }} {{ topic['comments_count'] == 1 ? 'Comment' : 'Comments' }}</div>
</div>
<div>
{% for comment in comments %}
<div class="comment">
<div class="comment-author">
<span class="icon material-icons-outlined">account_box</span>
<span>{{ comment['author_name'] }}</span>
</div>
{% if comment['reply_to_content'] is not null %}
<div style="padding-left: 4px;
border-left: 2px solid #6d757d;
margin-bottom: 4px;
margin-left: 4px;
color: #6d757d;
font-size: 13px;">
{{ comment['reply_to_content'] }}
</div>
{% endif %}
{% if commentImages[comment['id']] is not null %}
<div style="margin-top: 8px;margin-bottom: 8px;">
{% for imageId in commentImages[comment['id']] %}
<a href="/images/{{ imageId }}" target="_blank">
<img style="max-width: 90px;max-height: 90px;" src="/images/{{ imageId }}">
</a>
{% endfor %}
</div>
{% endif %}
<div class="comment-content">
{{ comment['content'] }}
</div>
<div class="comment-actions">
<a href="/{{ category['slug'] }}/{{ topic['slug'] }}/add-comment?reply_to={{ comment['id'] }}">
<span class="icon material-icons">reply</span>
<span class="comment-action-text">Reply</span>
</a>
{% if commentAccess.canChange(category['id'], comment['author_id']) %}
<a href="/comments/{{ comment['id'] }}">
<span class="icon material-icons">edit</span>
<span class="comment-action-text">Edit</span>
</a>
{% endif %}
{% if commentAccess.canDelete(category['id'], comment['author_id']) %}
<a href="/comments/{{ comment['id'] }}/delete">
<span class="icon material-icons">delete</span>
<span class="comment-action-text">Delete</span>
</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% if pages > 1 %}
<div class="pagination">
<span>{{ page }} of {{ pages }} pages</span>
<div>
{% if page > 2 %}
<a class="pagination-action" href="/{{ category['slug'] }}/{{ topic['slug'] }}?page=1">First page</a>
{% endif %}
{% if page > 1 %}
<a class="pagination-action" href="/{{ category['slug'] }}/{{ topic['slug'] }}?page={{ page - 1 }}">Back</a>
{% endif %}
{% if page < pages %}
<a class="pagination-action" href="/{{ category['slug'] }}/{{ topic['slug'] }}?page={{ page + 1 }}">Next</a>
{% endif %}
{% if page < pages and (pages - page) > 1 %}
<a class="pagination-action" href="/{{ category['slug'] }}/{{ topic['slug'] }}?page={{ pages }}">Last page</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</section>
{% endif %}
</section>
<section class="add-comment-section">
<form class="page-box" method="post" action="/{{ category['slug'] }}/{{ topic['slug'] }}/add-comment" enctype="multipart/form-data">
<div class="secondary-title">Add comment</div>
<div>
<textarea class="form-input" rows="3" name="content" required placeholder="Type your comment here..."></textarea>
</div>
<div style="margin-top: 8px;">
<input name="images[]" type="file" multiple />
</div>
<button class="form-button" type="submit">Add comment</button>
</form>
</section>
</body>
</html>

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
return [
'excluding_slugs' => [
'add-topic',
],
];

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
return [
'topic:show' => [
'pattern' => '/{categorySlug}/{slug}',
'paths' => [
'namespace' => 'App\Forum\Topic\Controllers',
'controller' => 'show',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
'topic:add' => [
'pattern' => '/{categorySlug}/add-topic',
'paths' => [
'namespace' => 'App\Forum\Topic\Controllers',
'controller' => 'add',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
'topic:delete' => [
'pattern' => '/topics/{id}/delete',
'paths' => [
'namespace' => 'App\Forum\Topic\Controllers',
'controller' => 'delete',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
'topic:edit' => [
'pattern' => '/topics/{id}',
'paths' => [
'namespace' => 'App\Forum\Topic\Controllers',
'controller' => 'edit',
'action' => 'main',
],
'httpMethods' => ['GET', 'POST'],
],
];

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Controllers;
use App\SharedKernel\StringConverter;
trait ModuleViewRender
{
public function renderView(array $vars = []): void
{
$appDir = str_replace(
StringConverter::classNameToDir(ModuleViewRender::class),
'',
__DIR__
);
$classNameExploded = explode('\\', get_called_class());
$viewName = strtolower(str_replace('Controller', '', end($classNameExploded)));
echo $this->view->render(
$appDir . StringConverter::classNameToDir(get_called_class()) . '/../Views/' . $viewName,
$vars
);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Controllers;
trait Pagination
{
public function getCurrentPage(): int
{
return (int) $this->request->getQuery('page', 'int', 1);
}
public function getSkipRowsNumber(int $rowsPerPage): int
{
return ($this->getCurrentPage() - 1) * $rowsPerPage;
}
public function getTotalPages(int $rowsTotal, int $rowsPerPage): int
{
return (int) ceil($rowsTotal / $rowsPerPage);
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Controllers;
trait Validation
{
/**
* @throws \InvalidArgumentException
*/
public function validatePostRequest(array $rules): void
{
(new \App\SharedKernel\Http\Validation($rules))->validate($_POST);
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Exceptions;
abstract class AccessDeniedException extends \DomainException
{
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Exceptions;
abstract class NotFoundException extends \DomainException
{
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\File\Controllers;
use App\SharedKernel\File\Models\FileRepository;
final class DeleteController extends \Phalcon\Mvc\Controller
{
public function mainAction($id): void
{
(new FileRepository())->get($id)->delete();
$this->response->redirect($_GET['redirect_to']);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\File\Controllers;
use App\SharedKernel\File\Models\FileRepository;
final class ImagePreviewController extends \Phalcon\Mvc\Controller
{
public function mainAction($id): void
{
$file = (new FileRepository())->get($id);
header('Cache-Control: No-Store');
header('Content-Type:' . $file->mime_type);
header('Content-Length: ' . filesize($file->placement));
readfile($file->placement);
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\File\Models;
final class File extends \Phalcon\Mvc\Model
{
public function initialize(): void
{
$this->setSource('files');
}
public static function addForForumTopic($id, $forumTopicId, string $tmpName, string $originalName): void
{
$mimeType = self::extractMimeType($tmpName);
$placement = self::getConfig()['files_dir'] . '/' . $id;
$file = new File([
'id' => $id,
'name' => $originalName,
'mime_type' => $mimeType,
'placement' => $placement,
'created_at' => (new \DateTime('now'))->format('Y-m-d H:i:s'),
'relation_table' => 'forum_topics',
'relation_id' => $forumTopicId,
]);
move_uploaded_file($tmpName, $placement);
$file->save();
}
public static function addForForumComment($id, $forumCommentId, string $tmpName, string $originalName): void
{
$mimeType = self::extractMimeType($tmpName);
$placement = self::getConfig()['files_dir'] . '/' . $id;
$file = new File([
'id' => $id,
'name' => $originalName,
'mime_type' => $mimeType,
'placement' => $placement,
'created_at' => (new \DateTime('now'))->format('Y-m-d H:i:s'),
'relation_table' => 'forum_comments',
'relation_id' => $forumCommentId,
]);
move_uploaded_file($tmpName, $placement);
$file->save();
}
private static function getConfig(): array
{
return include __DIR__ . '/../config.php';
}
public function delete(): void
{
unlink($this->placement);
parent::delete();
}
public function getImageBase64Content(): string
{
if (!self::isImageMimeType($this->mime_type)) {
return '';
}
$data = file_get_contents($this->placement);
$type = pathinfo($this->placement, PATHINFO_EXTENSION);
return 'data:image/' . $type . ';base64,' . base64_encode($data);
}
public static function isImageMimeType(string $mimeType): bool
{
return str_contains($mimeType, 'image/');
}
public static function extractMimeType(string $placement): string
{
return (new \finfo(FILEINFO_MIME_TYPE))->file($placement);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\File\Models;
use App\SharedKernel\Exceptions\NotFoundException;
final class FileNotFound extends NotFoundException
{
public function __construct()
{
parent::__construct('File not found');
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\File\Models;
final class FileReadRepository
{
public function findByForumTopicId($id): array
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$sql = "SELECT * FROM files WHERE relation_table = 'forum_topics' AND relation_id = '$id'";
$query = $connection->query($sql);
$query->setFetchMode(\Phalcon\Db::FETCH_ASSOC);
return $query->fetch();
}
public function findByForumCommentId($id): array
{
$connection = \Phalcon\DI::getDefault()->getShared('db');
$sql = "SELECT * FROM files WHERE relation_table = 'forum_comments' AND relation_id = '$id'";
$query = $connection->query($sql);
$query->setFetchMode(\Phalcon\Db::FETCH_ASSOC);
return $query->fetch();
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\File\Models;
final class FileRepository
{
public function get($id): File
{
$file = File::findFirst("id = '$id'");
if ($file === false) {
throw new FileNotFound();
}
return $file;
}
public function findByForumTopicId($id)
{
return File::find("relation_table = 'forum_topics' AND relation_id = '$id'");
}
public function findByForumCommentId($id)
{
return File::find("relation_table = 'forum_comments' AND relation_id = '$id'");
}
public function findByForumCommentsIds(array $ids)
{
$idsRow = '\'' . implode('\', \'', $ids) . '\'';
return File::find("relation_table = 'forum_comments' AND relation_id IN ($idsRow)");
}
}

View File

@ -0,0 +1,5 @@
<?php
return [
'files_dir' => __DIR__ . '/../../../storage/files',
];

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'file:image-preview' => [
'pattern' => '/images/{id}',
'paths' => [
'namespace' => 'App\SharedKernel\File\Controllers',
'controller' => 'image_preview',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
'file:delete' => [
'pattern' => '/files/{id}/delete',
'paths' => [
'namespace' => 'App\SharedKernel\File\Controllers',
'controller' => 'delete',
'action' => 'main',
],
'httpMethods' => ['GET'],
],
];

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Http;
final class RequestFilesNormalizer
{
public static function normalize(array $requestFiles): array
{
$normalizedFiles = [];
$count = count($requestFiles['name']);
$keys = array_keys($requestFiles);
for ($i = 0; $i < $count; $i++) {
foreach ($keys as $key) {
$normalizedFiles[$i][$key] = $requestFiles[$key][$i];
}
}
return $normalizedFiles;
}
}

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Http;
use App\SharedKernel\StringConverter;
use Phalcon\Validation\Validator\PresenceOf;
use Phalcon\Validation\Validator\Callback;
use Phalcon\Validation\Message;
/**
* @method validate($data)
*/
class Validation extends \Phalcon\Validation
{
private $rules;
public function __construct(array $rules)
{
$this->rules = $rules;
parent::__construct();
}
public function initialize()
{
foreach ($this->rules as $argument => $rules) {
$rules = explode('|', $rules);
foreach ($rules as $rule) {
list($rule, $value) = explode(':', $rule);
switch ($rule) {
case 'required':
$this->add(
$argument,
new PresenceOf(
[
'message' => new Message(
sprintf(
'%s is required',
StringConverter::snakeCaseToReadable($argument, true)
)
),
]
)
);
break;
case 'between':
$values = explode(',', $value);
$this->add(
$argument,
new Callback([
'callback' => function (array $data) use ($argument, $values): bool {
if (!isset($data[$argument])) {
return false;
}
return in_array((int) $data[$argument], range($values[0], $values[1]));
},
'message' => new Message(
sprintf(
'%s must be between %d and %d',
StringConverter::snakeCaseToReadable($argument, true),
$values[0],
$values[1]
)
),
])
);
break;
case 'equal':
$this->add(
$argument,
new Callback([
'callback' => function (array $data) use ($argument, $value): bool {
if (!isset($data[$argument])) {
return false;
}
return $data[$argument] === $value;
},
'message' => new Message(
sprintf(
'%s must be only %s',
StringConverter::snakeCaseToReadable($argument, true),
$value
)
),
])
);
break;
case 'length_between':
$values = explode(',', $value);
$this->add(
$argument,
new Callback([
'callback' => function (array $data) use ($argument, $values): bool {
if (!isset($data[$argument])) {
return false;
}
$length = strlen(trim($data[$argument]));
return $length >= $values[0] && $length <= $values[1];
},
'message' => new Message(
sprintf(
'Length of %s must be between %d and %d symbols',
StringConverter::snakeCaseToReadable($argument),
$values[0],
$values[1]
)
),
])
);
break;
}
}
}
}
public function afterValidation($data, $entity, $messages)
{
if (count($messages)) {
foreach ($messages as $message) {
throw new \InvalidArgumentException((string) $message);
}
}
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel;
final class RandomStringGenerator
{
private const SYMBOLS_COLLECTION = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private const NUMBERS_COLLECTION = '0123456789';
public static function generate(int $length): string
{
$input = self::NUMBERS_COLLECTION . self::SYMBOLS_COLLECTION;
$inputLength = strlen($input);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomCharacter = $input[rand(0, $inputLength - 1)];
$randomString .= $randomCharacter;
}
return $randomString;
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel;
final class StringConverter
{
public static function snakeCaseToCamelCase(string $value): string
{
$result = str_replace(' ', '', ucwords(str_replace('_', ' ', $value)));
$result[0] = strtolower($result[0]);
return $result;
}
public static function snakeCaseToReadable(string $value, bool $upFirst = false): string
{
$result = str_replace('_', ' ', $value);
return $upFirst ? ucfirst($result) : $result;
}
public static function readableToSlug(string $value): string
{
$divider = '-';
$value = preg_replace('~[^\pL\d]+~u', $divider, $value);
$value = iconv('utf-8', 'us-ascii//TRANSLIT', $value);
$value = preg_replace('~[^-\w]+~', '', $value);
$value = trim($value, $divider);
$value = preg_replace('~-+~', $divider, $value);
return strtolower($value);
}
public static function classNameToDir(string $value): string
{
$classNameExploded = explode('\\', $value);
return substr(
lcfirst(
str_replace(
'\\',
'/',
str_replace(
end($classNameExploded),
'',
$value
)
)
),
0,
-1
);
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Structs;
use App\SharedKernel\StringConverter;
abstract class Enum extends ValueObject
{
/**
* @throws \InvalidArgumentException
*/
public static function fromValue(string $value): self
{
$methodName = StringConverter::snakeCaseToCamelCase($value);
if (!method_exists(static::class, $methodName)) {
throw new \InvalidArgumentException(sprintf('Passed value \'%s\' doesn\'t allowed here', $value));
}
return static::$methodName();
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Structs;
abstract class ValueObject implements \Stringable
{
public $value;
public function __construct($value)
{
$this->value = $value;
}
public function __toString(): string
{
return (string) $this->value;
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel\Tasks;
final class RunMigrationTask extends \Phalcon\Cli\Task
{
public function mainAction()
{
$migrationsDirectory = __DIR__ . '/../../../migrations';
$this->createMigrationsTableIfNotExist();
$candidates = array_map(
static function (string $file): int {
return (int) explode('.', $file)[0];
},
array_diff(scandir($migrationsDirectory), ['..', '.'])
);
sort($candidates);
$last = $this->getLastExecuted();
$counter = 0;
foreach ($candidates as $candidate) {
if ($candidate > $last) {
$filename = $migrationsDirectory . '/' . $candidate . '.sql';
$this->db->execute(file_get_contents($filename));
$this->setLastExecuted($candidate);
$counter++;
}
}
echo sprintf('Migrated %d', $counter);
echo PHP_EOL;
}
private function createMigrationsTableIfNotExist()
{
$this->db->execute('CREATE TABLE IF NOT EXISTS migrations (version BIGINT NOT NULL, PRIMARY KEY(version))');
}
private function getLastExecuted(): int
{
return (int) $this->db->fetchColumn('SELECT version FROM migrations ORDER BY version DESC LIMIT 1');
}
private function setLastExecuted(int $version): void
{
$this->db->execute(sprintf('INSERT INTO migrations VALUES (%d)', $version));
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\SharedKernel;
use App\SharedKernel\Structs\Enum;
final class TimeSorting extends Enum
{
public static function newest(): self
{
return new self('newest');
}
public static function oldest(): self
{
return new self('oldest');
}
}

View File

@ -0,0 +1,14 @@
<html lang="en">
<head>
<title>{{ appName }}</title>
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
</head>
<body>
<div class="container" style="justify-content: center;display: flex;height: 100vh;align-items: center;text-align: center;">
<div>
<h1>{{ code }}</h1>
<h2>{{ message }}</h2>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,25 @@
<header>
<div class="page-box header-main">
<div class="header-logo">
<img src="/assets/img/logo.svg">
</div>
{% if user is not null %}
<div class="username">
<span>{{ user.name }}</span>
<span class="material-icons-outlined">account_box</span>
</div>
{% endif %}
</div>
<div class="page-box header-menu">
<div>
<a class="margin-right-8 text-gray" href="/">Topics</a>
<span class="margin-right-8 text-primary">Users</span>
{% if user is null %}
<a class="text-gray" href="/login">Login</a>
{% else %}
<a class="text-gray" href="/logout">Logout</a>
{% endif %}
</div>
</div>
</header>

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\User\Console;
use App\Access\Models\Role;
use App\User\Models\User;
use App\User\Models\UserNotFound;
use App\User\Models\UserRepository;
use Ramsey\Uuid\Uuid;
final class CreateFirstAdminTask extends \Phalcon\Cli\Task
{
public function mainAction(): void
{
try {
$this->getUserRepository()->getByName($this->config['user']['admin_name']);
echo 'Default admin user already exists' . PHP_EOL;
} catch (UserNotFound $exception) {
User::add(
Uuid::uuid4(),
$this->config['user']['admin_name'],
$this->config['user']['admin_password'],
Role::admin()
);
echo 'Admin user created' . PHP_EOL;
echo 'Name: ' . $this->config['user']['admin_name'] . PHP_EOL;
echo 'Password: ' . $this->config['user']['admin_password'] . PHP_EOL;
}
}
private function getUserRepository(): UserRepository
{
return new UserRepository();
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\User\Controllers;
use App\Access\Models\AccessChecker\User\AccessChecker;
use App\Access\Models\Forbidden;
use App\Access\Models\Role;
use App\Auth\Models\Auth;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\Http\Validation;
use App\User\Models\User;
use Ramsey\Uuid\Uuid;
final class AddController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
public function mainAction(): void
{
if (!$this->getAccessChecker()->canManageUsers()) {
throw new Forbidden();
}
if ($this->request->isPost()) {
try {
$validation = new Validation([
'name' => 'required|length_between:3,36',
'password' => 'required|length_between:6,36',
]);
$validation->validate($_POST);
$user = $this->getAuth()->getUserFromSession();
$addingUserId = Uuid::uuid4();
User::add(
$addingUserId,
$_POST['name'],
$_POST['password'],
Role::user(),
$user->id
);
$this->response->redirect('/users/' . $addingUserId);
} catch (\LogicException $e) {
$this->renderView(['error' => $e->getMessage(), 'name' => $_POST['name']]);
return;
}
}
$this->renderView();
}
private function getAuth(): Auth
{
return new Auth();
}
private function getAccessChecker(): AccessChecker
{
return new AccessChecker();
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\User\Controllers;
use App\Access\Models\AccessChecker\User\AccessChecker;
use App\Access\Models\Forbidden;
use App\User\Models\UserRepository;
final class DeleteController extends \Phalcon\Mvc\Controller
{
public function mainAction(string $id): void
{
if (!$this->getAccessChecker()->canManageUsers()) {
throw new Forbidden();
}
$user = $this->getUserRepository()->get($id);
$user->delete();
$this->response->redirect('/users');
}
private function getUserRepository(): UserRepository
{
return new UserRepository();
}
private function getAccessChecker(): AccessChecker
{
return new AccessChecker();
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\User\Controllers;
use App\Access\Models\AccessChecker\User\AccessChecker;
use App\Access\Models\Forbidden;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\SharedKernel\Controllers\Pagination;
use App\User\Models\UserRepository;
final class IndexController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
use Pagination;
public function mainAction(): void
{
if (!$this->getAccessChecker()->canManageUsers()) {
throw new Forbidden();
}
$rowsPerPage = 10;
$users = $this->getUserRepository()->find(
$rowsPerPage,
$this->getSkipRowsNumber($rowsPerPage)
);
$this->renderView([
'users' => $users,
'page' => $this->getCurrentPage(),
'pages' => $this->getTotalPages($this->getUserRepository()->count(), $rowsPerPage),
]);
}
private function getUserRepository(): UserRepository
{
return new UserRepository();
}
private function getAccessChecker(): AccessChecker
{
return new AccessChecker();
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\User\Controllers;
use App\Access\Models\AccessChecker\User\AccessChecker;
use App\Access\Models\Forbidden;
use App\SharedKernel\Controllers\ModuleViewRender;
use App\User\Models\UserRepository;
final class ShowController extends \Phalcon\Mvc\Controller
{
use ModuleViewRender;
public function mainAction(string $id): void
{
if (!$this->getAccessChecker()->canManageUsers()) {
throw new Forbidden();
}
$this->renderView(['user1' => $this->getUserRepository()->get($id)]);
}
private function getUserRepository(): UserRepository
{
return new UserRepository();
}
private function getAccessChecker(): AccessChecker
{
return new AccessChecker();
}
}

46
app/User/Models/User.php Normal file
View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\User\Models;
use App\Access\Models\Role;
final class User extends \Phalcon\Mvc\Model
{
public function initialize(): void
{
$this->setSource('users');
}
public static function add(
$id,
string $name,
string $password,
Role $role,
$userId = null
): void {
if ((new UserRepository())->existByName($name)) {
throw new UserAlreadyExist();
}
$user = new self([
'id' => $id,
'name' => strtolower($name),
'password_hash' => hash('sha256', $password),
'role' => $role,
'created_at' => (new \DateTime('now'))->format('Y-m-d H:i:s'),
'created_by' => $userId,
]);
$user->save();
}
public function assignRole(Role $role, $userId): void
{
$this->role = $role;
$this->updated_at = (new \DateTime('now'))->format('Y-m-d H:i:s');
$this->updated_by = $userId;
$this->save();
}
}

Some files were not shown because too many files have changed in this diff Show More