Initial
This commit is contained in:
commit
2f27bc593c
11
.env.dist
Normal file
11
.env.dist
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
vendor
|
||||
storage/postgres-data
|
||||
.env
|
||||
docker-compose.yml
|
65
app/Access/Controllers/AssignCategoryController.php
Normal file
65
app/Access/Controllers/AssignCategoryController.php
Normal 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();
|
||||
}
|
||||
}
|
58
app/Access/Controllers/AssignRoleController.php
Normal file
58
app/Access/Controllers/AssignRoleController.php
Normal 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();
|
||||
}
|
||||
}
|
41
app/Access/Controllers/DeleteModerateCategoryController.php
Normal file
41
app/Access/Controllers/DeleteModerateCategoryController.php
Normal 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();
|
||||
}
|
||||
}
|
48
app/Access/Controllers/ModerateCategoriesController.php
Normal file
48
app/Access/Controllers/ModerateCategoriesController.php
Normal 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();
|
||||
}
|
||||
}
|
29
app/Access/Models/AccessChecker/Access/AccessChecker.php
Normal file
29
app/Access/Models/AccessChecker/Access/AccessChecker.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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'");
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
79
app/Access/Models/AccessChecker/Forum/TopicAccessChecker.php
Normal file
79
app/Access/Models/AccessChecker/Forum/TopicAccessChecker.php
Normal 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;
|
||||
}
|
||||
}
|
29
app/Access/Models/AccessChecker/User/AccessChecker.php
Normal file
29
app/Access/Models/AccessChecker/User/AccessChecker.php
Normal 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;
|
||||
}
|
||||
}
|
10
app/Access/Models/Forbidden.php
Normal file
10
app/Access/Models/Forbidden.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Access\Models;
|
||||
|
||||
final class Forbidden extends \Exception
|
||||
{
|
||||
|
||||
}
|
25
app/Access/Models/Role.php
Normal file
25
app/Access/Models/Role.php
Normal 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');
|
||||
}
|
||||
}
|
76
app/Access/Views/assign-category.volt
Normal file
76
app/Access/Views/assign-category.volt
Normal 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>
|
73
app/Access/Views/assign-role.volt
Normal file
73
app/Access/Views/assign-role.volt
Normal 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>
|
79
app/Access/Views/moderate-categories.volt
Normal file
79
app/Access/Views/moderate-categories.volt
Normal 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
42
app/Access/routes.php
Normal 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'],
|
||||
],
|
||||
];
|
44
app/Auth/Controllers/LoginController.php
Normal file
44
app/Auth/Controllers/LoginController.php
Normal 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();
|
||||
}
|
||||
}
|
22
app/Auth/Controllers/LogoutController.php
Normal file
22
app/Auth/Controllers/LogoutController.php
Normal 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
73
app/Auth/Models/Auth.php
Normal 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');
|
||||
}
|
||||
}
|
13
app/Auth/Models/IsNotAuthenticated.php
Normal file
13
app/Auth/Models/IsNotAuthenticated.php
Normal 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');
|
||||
}
|
||||
}
|
13
app/Auth/Models/LoginFailed.php
Normal file
13
app/Auth/Models/LoginFailed.php
Normal 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
66
app/Auth/Views/login.volt
Normal 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
24
app/Auth/routes.php
Normal 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'],
|
||||
],
|
||||
];
|
60
app/Forum/Category/Controllers/AddController.php
Normal file
60
app/Forum/Category/Controllers/AddController.php
Normal 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();
|
||||
}
|
||||
}
|
35
app/Forum/Category/Controllers/DeleteController.php
Normal file
35
app/Forum/Category/Controllers/DeleteController.php
Normal 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();
|
||||
}
|
||||
}
|
56
app/Forum/Category/Controllers/EditController.php
Normal file
56
app/Forum/Category/Controllers/EditController.php
Normal 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();
|
||||
}
|
||||
}
|
38
app/Forum/Category/Controllers/IndexController.php
Normal file
38
app/Forum/Category/Controllers/IndexController.php
Normal 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();
|
||||
}
|
||||
}
|
50
app/Forum/Category/Controllers/ShowController.php
Normal file
50
app/Forum/Category/Controllers/ShowController.php
Normal 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();
|
||||
}
|
||||
}
|
54
app/Forum/Category/Models/Category.php
Normal file
54
app/Forum/Category/Models/Category.php
Normal 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();
|
||||
}
|
||||
}
|
13
app/Forum/Category/Models/CategoryAlreadyExist.php
Normal file
13
app/Forum/Category/Models/CategoryAlreadyExist.php
Normal 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');
|
||||
}
|
||||
}
|
15
app/Forum/Category/Models/CategoryNotFound.php
Normal file
15
app/Forum/Category/Models/CategoryNotFound.php
Normal 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');
|
||||
}
|
||||
}
|
74
app/Forum/Category/Models/CategoryReadRepository.php
Normal file
74
app/Forum/Category/Models/CategoryReadRepository.php
Normal 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();
|
||||
}
|
||||
}
|
37
app/Forum/Category/Models/CategoryWriteRepository.php
Normal file
37
app/Forum/Category/Models/CategoryWriteRepository.php
Normal 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();
|
||||
}
|
||||
}
|
27
app/Forum/Category/Models/UniqueSlugGenerator.php
Normal file
27
app/Forum/Category/Models/UniqueSlugGenerator.php
Normal 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++;
|
||||
}
|
||||
}
|
||||
}
|
78
app/Forum/Category/Views/add.volt
Normal file
78
app/Forum/Category/Views/add.volt
Normal 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>
|
79
app/Forum/Category/Views/edit.volt
Normal file
79
app/Forum/Category/Views/edit.volt
Normal 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>
|
98
app/Forum/Category/Views/index.volt
Normal file
98
app/Forum/Category/Views/index.volt
Normal 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>
|
111
app/Forum/Category/Views/show.volt
Normal file
111
app/Forum/Category/Views/show.volt
Normal 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>
|
17
app/Forum/Category/config.php
Normal file
17
app/Forum/Category/config.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'excluding_slugs' => [
|
||||
'access',
|
||||
'login',
|
||||
'logout',
|
||||
'categories',
|
||||
'add-category',
|
||||
'comments',
|
||||
'topics',
|
||||
'users',
|
||||
'add-user',
|
||||
],
|
||||
];
|
51
app/Forum/Category/routes.php
Normal file
51
app/Forum/Category/routes.php
Normal 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'],
|
||||
],
|
||||
];
|
137
app/Forum/Comment/Controllers/AddController.php
Normal file
137
app/Forum/Comment/Controllers/AddController.php
Normal 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();
|
||||
}
|
||||
}
|
49
app/Forum/Comment/Controllers/DeleteController.php
Normal file
49
app/Forum/Comment/Controllers/DeleteController.php
Normal 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();
|
||||
}
|
||||
}
|
128
app/Forum/Comment/Controllers/EditController.php
Normal file
128
app/Forum/Comment/Controllers/EditController.php
Normal 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();
|
||||
}
|
||||
}
|
81
app/Forum/Comment/Models/Comment.php
Normal file
81
app/Forum/Comment/Models/Comment.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
12
app/Forum/Comment/Models/CommentNotFound.php
Normal file
12
app/Forum/Comment/Models/CommentNotFound.php
Normal 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
|
||||
{
|
||||
|
||||
}
|
43
app/Forum/Comment/Models/CommentReadRepository.php
Normal file
43
app/Forum/Comment/Models/CommentReadRepository.php
Normal 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];
|
||||
}
|
||||
}
|
50
app/Forum/Comment/Models/CommentWriteRepository.php
Normal file
50
app/Forum/Comment/Models/CommentWriteRepository.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
101
app/Forum/Comment/Views/add.volt
Normal file
101
app/Forum/Comment/Views/add.volt
Normal 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>
|
97
app/Forum/Comment/Views/edit.volt
Normal file
97
app/Forum/Comment/Views/edit.volt
Normal 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>
|
5
app/Forum/Comment/config.php
Normal file
5
app/Forum/Comment/config.php
Normal file
@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [];
|
33
app/Forum/Comment/routes.php
Normal file
33
app/Forum/Comment/routes.php
Normal 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'],
|
||||
],
|
||||
];
|
109
app/Forum/Topic/Controllers/AddController.php
Normal file
109
app/Forum/Topic/Controllers/AddController.php
Normal 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();
|
||||
}
|
||||
}
|
43
app/Forum/Topic/Controllers/DeleteController.php
Normal file
43
app/Forum/Topic/Controllers/DeleteController.php
Normal 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();
|
||||
}
|
||||
}
|
125
app/Forum/Topic/Controllers/EditController.php
Normal file
125
app/Forum/Topic/Controllers/EditController.php
Normal 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();
|
||||
}
|
||||
}
|
76
app/Forum/Topic/Controllers/ShowController.php
Normal file
76
app/Forum/Topic/Controllers/ShowController.php
Normal 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();
|
||||
}
|
||||
}
|
59
app/Forum/Topic/Models/Topic.php
Normal file
59
app/Forum/Topic/Models/Topic.php
Normal 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();
|
||||
}
|
||||
}
|
12
app/Forum/Topic/Models/TopicNotFound.php
Normal file
12
app/Forum/Topic/Models/TopicNotFound.php
Normal 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
|
||||
{
|
||||
|
||||
}
|
90
app/Forum/Topic/Models/TopicReadRepository.php
Normal file
90
app/Forum/Topic/Models/TopicReadRepository.php
Normal 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();
|
||||
}
|
||||
}
|
43
app/Forum/Topic/Models/TopicWriteRepository.php
Normal file
43
app/Forum/Topic/Models/TopicWriteRepository.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
27
app/Forum/Topic/Models/UniqueSlugGenerator.php
Normal file
27
app/Forum/Topic/Models/UniqueSlugGenerator.php
Normal 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++;
|
||||
}
|
||||
}
|
||||
}
|
114
app/Forum/Topic/Views/add.volt
Normal file
114
app/Forum/Topic/Views/add.volt
Normal 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>
|
137
app/Forum/Topic/Views/edit.volt
Normal file
137
app/Forum/Topic/Views/edit.volt
Normal 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>
|
191
app/Forum/Topic/Views/show.volt
Normal file
191
app/Forum/Topic/Views/show.volt
Normal 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>
|
9
app/Forum/Topic/config.php
Normal file
9
app/Forum/Topic/config.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'excluding_slugs' => [
|
||||
'add-topic',
|
||||
],
|
||||
];
|
42
app/Forum/Topic/routes.php
Normal file
42
app/Forum/Topic/routes.php
Normal 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'],
|
||||
],
|
||||
];
|
27
app/SharedKernel/Controllers/ModuleViewRender.php
Normal file
27
app/SharedKernel/Controllers/ModuleViewRender.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
23
app/SharedKernel/Controllers/Pagination.php
Normal file
23
app/SharedKernel/Controllers/Pagination.php
Normal 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);
|
||||
}
|
||||
}
|
16
app/SharedKernel/Controllers/Validation.php
Normal file
16
app/SharedKernel/Controllers/Validation.php
Normal 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);
|
||||
}
|
||||
}
|
10
app/SharedKernel/Exceptions/AccessDeniedException.php
Normal file
10
app/SharedKernel/Exceptions/AccessDeniedException.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Exceptions;
|
||||
|
||||
abstract class AccessDeniedException extends \DomainException
|
||||
{
|
||||
|
||||
}
|
10
app/SharedKernel/Exceptions/NotFoundException.php
Normal file
10
app/SharedKernel/Exceptions/NotFoundException.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Exceptions;
|
||||
|
||||
abstract class NotFoundException extends \DomainException
|
||||
{
|
||||
|
||||
}
|
17
app/SharedKernel/File/Controllers/DeleteController.php
Normal file
17
app/SharedKernel/File/Controllers/DeleteController.php
Normal 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']);
|
||||
}
|
||||
}
|
20
app/SharedKernel/File/Controllers/ImagePreviewController.php
Normal file
20
app/SharedKernel/File/Controllers/ImagePreviewController.php
Normal 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);
|
||||
}
|
||||
}
|
87
app/SharedKernel/File/Models/File.php
Normal file
87
app/SharedKernel/File/Models/File.php
Normal 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);
|
||||
}
|
||||
}
|
15
app/SharedKernel/File/Models/FileNotFound.php
Normal file
15
app/SharedKernel/File/Models/FileNotFound.php
Normal 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');
|
||||
}
|
||||
}
|
32
app/SharedKernel/File/Models/FileReadRepository.php
Normal file
32
app/SharedKernel/File/Models/FileReadRepository.php
Normal 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();
|
||||
}
|
||||
}
|
36
app/SharedKernel/File/Models/FileRepository.php
Normal file
36
app/SharedKernel/File/Models/FileRepository.php
Normal 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)");
|
||||
}
|
||||
}
|
5
app/SharedKernel/File/config.php
Normal file
5
app/SharedKernel/File/config.php
Normal file
@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'files_dir' => __DIR__ . '/../../../storage/files',
|
||||
];
|
24
app/SharedKernel/File/routes.php
Normal file
24
app/SharedKernel/File/routes.php
Normal 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'],
|
||||
],
|
||||
];
|
23
app/SharedKernel/Http/RequestFilesNormalizer.php
Normal file
23
app/SharedKernel/Http/RequestFilesNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
127
app/SharedKernel/Http/Validation.php
Normal file
127
app/SharedKernel/Http/Validation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26
app/SharedKernel/RandomStringGenerator.php
Normal file
26
app/SharedKernel/RandomStringGenerator.php
Normal 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;
|
||||
}
|
||||
}
|
57
app/SharedKernel/StringConverter.php
Normal file
57
app/SharedKernel/StringConverter.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
24
app/SharedKernel/Structs/Enum.php
Normal file
24
app/SharedKernel/Structs/Enum.php
Normal 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();
|
||||
}
|
||||
}
|
20
app/SharedKernel/Structs/ValueObject.php
Normal file
20
app/SharedKernel/Structs/ValueObject.php
Normal 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;
|
||||
}
|
||||
}
|
53
app/SharedKernel/Tasks/RunMigrationTask.php
Normal file
53
app/SharedKernel/Tasks/RunMigrationTask.php
Normal 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));
|
||||
}
|
||||
}
|
20
app/SharedKernel/TimeSorting.php
Normal file
20
app/SharedKernel/TimeSorting.php
Normal 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');
|
||||
}
|
||||
}
|
14
app/SharedKernel/Views/error.volt
Normal file
14
app/SharedKernel/Views/error.volt
Normal 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>
|
25
app/SharedKernel/Views/partials/header.volt
Normal file
25
app/SharedKernel/Views/partials/header.volt
Normal 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>
|
39
app/User/Console/CreateFirstAdminTask.php
Normal file
39
app/User/Console/CreateFirstAdminTask.php
Normal 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();
|
||||
}
|
||||
}
|
67
app/User/Controllers/AddController.php
Normal file
67
app/User/Controllers/AddController.php
Normal 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();
|
||||
}
|
||||
}
|
34
app/User/Controllers/DeleteController.php
Normal file
34
app/User/Controllers/DeleteController.php
Normal 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();
|
||||
}
|
||||
}
|
45
app/User/Controllers/IndexController.php
Normal file
45
app/User/Controllers/IndexController.php
Normal 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();
|
||||
}
|
||||
}
|
34
app/User/Controllers/ShowController.php
Normal file
34
app/User/Controllers/ShowController.php
Normal 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
46
app/User/Models/User.php
Normal 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
Loading…
Reference in New Issue
Block a user