Как стать автором
Обновить

Пример доски объявлений на Kohana

Kohana

Kohana — довольно молодой PHP фреймворк, форк CI, всецело завязанный на ООП. К достоинствам Kohana можно отнести использование всех возможностей PHP5 на 100%, высокую скорость работы, «легковесность» и простоту как использования, так и изучения. Из минусов отчетливо выделяется небольшое комьюнити, как следствие, не шибко качественная документация и небольшое количество модулей и библиотек.

Не так давно своё знакомство с фреймворками я начинал именно с Kohana и, надо сказать, я был удивлен, насколько легко он мне поддался. Думал будет намного сложнее. Но очень сказывалась нехватка документации и примеров кода. Зарывшись в маны и разбор исходников, через некоторое время, я восполнил интересующие меня пробелы относительно Kohana, и поэтому решил написать эту статью, дабы другим хабралюдям, оказавшимся в схожей ситуации не пришлось по ночам не досыпать и лечить головные боли.

Под катом пример доски объявлений, написанный с помощью Kohana, возможно местами он не претендует на рациональность и здравый смысл, но всё-же я надеюсь услышать конструктивную критику.

Статья рассчитана на людей, имеющих понятие об MVC и ООП, но не имевших, либо мало имевших, дело с фреймворками.


Начнем


Недавно передо мной встала задача написать небольшую доску объявлений на базе новостного сайта, где пользователи могли бы оставлять свои объявления о купле, продаже и прочем. Написать очень быстро. Честно говоря, до этого случая весь мой опыт с фреймворками заключался в установке сэндбокса Symfony и последующем его удалением. А всё, что я знал о фреймворках, так это то, что большинство из них «используют MVC» и то, что они очень облегчают жизнь. Т.к. в то время я уже почитывал Хабр, мне почему-то запала в голову одна из публикаций, которая утверждала, что фреймворк Kohana «cоздан быть легким, быстрым и простым в использовании». Думаю именно поэтому я выбрал его. Итак,

Что мы хотим получить в результате?


Мы хотим, чтобы у нас были такие возможности в отношении пользователей:
  • авторизация/регистрация пользователей
  • просмотр объявлений всеми пользователями;
  • добавлять объявления могут только зарегистрированные;
  • править/удалять могут только авторы и админы;

Условия для категорий:
  • существуют главные категории;
  • существуют под-категории;
  • под-категории содержат объявления;


С тем, что от нас нужно, мы определились, теперь придумаем, как мы это будем реализовывать.

Для регистрации пользователей будем использовать модуль Auth, который входит в стандартную поставку версии 2.3. Для его использования создадим в базе несколько таблиц:
# в этой таблице мы будем хранить пользователей<br/>
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) unsigned NOT NULL auto_increment,
`username` varchar(32) NOT NULL default '',
`password` char(50) NOT NULL default '',
`email` varchar(127) NOT NULL default '',
`join` int(10) unsigned NOT NULL default '0',
`last_login` int(10) unsigned NOT NULL default '0',
`logins` int(10) unsigned NOT NULL default '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_username` (`username`),
UNIQUE KEY `uniq_email` (`email`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=3;
INSERT INTO `users` (`id`, `username`, `password`, `email`, `join`, `last_login`, `logins`) VALUES (1, 'admin', '098f6bcd4621d373cade4e832627b4f6', 'example@example.com', 1215075372, 0, 0);

# тут у нас будут описания прав пользователей<br/>
CREATE TABLE IF NOT EXISTS `roles` (
`id` int(4) unsigned NOT NULL auto_increment,
`name` varchar(32) NOT NULL,
`description` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_name` (`name`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=3;
INSERT INTO `roles` (`id`, `name`, `description`) VALUES (1, 'login', 'Зарегистрированный');
INSERT INTO `roles` (`id`, `name`, `description`) VALUES (2, 'admin', 'Админ');

# эта таблица будет содержать привязку прав к пользователю<br/>
CREATE TABLE IF NOT EXISTS `roles_users` (
`user_id` int(10) unsigned NOT NULL,
`role_id` int(10) unsigned NOT NULL,
PRIMARY KEY (`user_id`,`role_id`),
KEY `fk_role_id` (`role_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
INSERT INTO `roles_users` (`user_id`, `role_id`) VALUES (1, 2);

# a эта таблица будет использоваться модулем Auth для хранения залогиненых пользователей<br/>
DROP TABLE IF EXISTS `d_user_tokens`;
CREATE TABLE IF NOT EXISTS `user_tokens` (
`id` int(11) unsigned NOT NULL auto_increment,
`user_id` int(11) unsigned NOT NULL,
`user_agent` varchar(40) NOT NULL,
`token` varchar(32) NOT NULL,
`created` int(10) unsigned NOT NULL,
`expires` int(10) unsigned NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_token` (`token`),
KEY `user_id` (`user_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;


Дальше создадим таблицы для категорий и объявлений. При этом все категории будут иметь поле parent_id, которое будет указывать на id родительской категории у которых parent_id будет равен 0.
CREATE TABLE IF NOT EXISTS `categories` (
`id` int(10) unsigned NOT NULL auto_increment,
`parent_id` int(10) unsigned NOT NULL default '0',
`name` varchar(150) character set utf8 NOT NULL,
PRIMARY KEY (`id`),
KEY `parent_id` (`parent_id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=14;
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (1, 0, 'Недвижимость');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (2, 1, 'Квартиры');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (3, 1, 'Комнаты');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (4, 1, 'Дома, дачи');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (5, 0, 'Авто');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (6, 5, 'Легковые автомобили');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (7, 5, 'Запчасти, аксессуары');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (8, 5, 'Мотоциклы, мопеды');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (9, 0, 'Работа');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (10, 9, 'Админ персонал');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (11, 9, 'ИТ, интернет');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (12, 9, 'Работа на дому');
INSERT INTO `categories` (`id`, `parent_id`, `name`) VALUES (13, 9, 'Другие сферы');

CREATE TABLE IF NOT EXISTS `items` (
`id` int(10) unsigned NOT NULL auto_increment,
`title` varchar(250) character set utf8 NOT NULL,
`category_id` int(10) unsigned NOT NULL default '0',
`content` text NOT NULL,
`user_id` int(10) unsigned NOT NULL,
`datepub` int(10) unsigned NOT NULL default '0',
PRIMARY KEY (`id`),
KEY `title` (`title`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 AUTO_INCREMENT=2;
INSERT INTO `items` (`id`, `title`, `category_id`, `content`, `user_id`, `datepub`) VALUES (1, 'Тестовое объявление', 2, 'Это просто объявление. Таких тут вскоре будет много', 1, 1229251545);


Модели


Модели — это часть MVC, которая представляет данные и реагирует на запросы, являясь конечной инстанцией между скриптом и БД.

Теперь о моделях, которые мы будем использовать. Для работы с пользователями у нас есть стандартные модели модуля Auth, для обращения к статьям и категориям, создадим свои — создайте файл /application/models/category.php и пропишите в нем следующий класс:
class Category_Model extends ORM_Tree{
protected $children = 'categories';
protected $has_many = array('items');
}

Теперь создайте ещё один файл /application/models/item.php с таким классом:
class Item_Model extends ORM{
protected $has_one = array('user');
protected $belongs_to = array('category');
}

Теперь на русском — для модели категорий мы создали расширенный ORM (об этом немного ниже) класс таблицы `categories` с древовидной структкрой (ORM_Tree), у записи которой могут быть потомки в виде записей из этой же таблицы (protected $children = `categories`, по умолчанию родитель — это поле `parent_id`) и каждая запись которой может иметь много вложенных записей таблицы `items`. А для модели объявлений у нас получился простой ORM класс таблицы `items`, которая содержит одну запись из таблицы `users` и пренадлежит таблице `categories`

Думаю, стоит прояснить, что названия моделей должны быть в единичном числе, т.е. если используется таблица items, то название модели должно быть Item_Model. Также дела обстоят и с объявлениями переменных модели, если предполагается множество объектов ($children, $has_many), то аргумент должен быть во множественном числе, если же объект один ($has_one, $belong_to), то и число — единичное.


Контроллеры


С моделями вроде разобрались, теперь о контроллерах — они служат для отображения информации, являясь прослойкой между моделями и видами.

Какие контроллеры нам нужны? Думаю такие:
  1. для главной страницы, заодно и категорий
  2. для под-категорий
  3. для просмотра объявлений
  4. для входа пользователей
  5. для их регистрации

Поскольку первый со вторым, также третий с четвертым, являются логически взаимосвязанными, объединим их в один, для каждого типа и в итоге получаем три контроллера — category.php, items.php и user.php.

Дальше много кода, большинство из которого интуитивно понятна, и занакома людям, работавшим с ORM и MVC.

Что такое ORM?
Думаю, что если Вы дочитали до сюда, то вопросов по MVC у Вас возникнуть не должно, а вот в отношении ORM они вполне могут появиться. Википедия нам говорит, что ORM — это технология программирования, которая связывает базы данных с концепциями объектно-ориентированных языков программирования, создавая «виртуальную объектную базу данных». Что это значит? Это значит, что вызвав метод factory класса ORM с параметром, к примеру, 'table', мы получим объект, проекцию таблицы `tables` (о множественных и единичных числах я говорил немного выше), и место того, чтобы делать SQL запросы к базе через расширение, или какой-либо абстрактный класс, мы можем обращаться к методам и свойствам вызваного класса. Т.е. например, выборка записей с id равным 5 из базы произойдет не привычным $db->query('SELECT * FROM `table` WHERE (`id` = 5) '), а обращением к ORM проекции — ORM::factory('table')->where('id', 5)->find_all(). Но в отличии от простого абстрактного доступа, ORM предоставляет массу вкусностей, которые сложно понять, не попробовав. Так, если речь идет именно об id, то запись можно получить ещё проще — ORM::factory('table', 5). На примере наших вышеобозначенных моделей, мы можем получить имя категории в которой лежит объявление с id = 5 вызовом ORM::factory('item', 5)->category->name, а не писать кучу SQL запросов.


category.php:
class Category_Controller extends Template_Controller {<br> public $template = 'index';<br><br> public function index() { // главная страница<br> $_result = '';<br> $_cats = array();<br> $categories = ORM::factory('category')->where('parent_id', 0)->find_all();<br> foreach($categories as $l) {<br> $_tmp = new View('category');<br> $_tmp->id = $l->id;<br> $_tmp->name = $l->name;<br> $_tmp->children = $l->children;<br> $_cats[] = $_tmp;<br> }<br> $_result = new View('category');<br> $_result->cats = $_cats;<br> $this->template->content = $_result;<br> }<br><br> public function view($params) { // прсмотр категории<br> $_result = '';<br> $categories = ORM::factory('category', $params)->children;<br> foreach($categories as $l) {<br> foreach ($l->items as $n) {<br> $_tmp = new View('item');<br> $_tmp->content = $n;<br> $_result.= $_tmp;<br> }<br> }<br> $this->template->content = $_result;<br> }<br><br> public function viewsub($params) { // просмотр под-категории<br> $_result = '';<br> $categories = ORM::factory('category', $params);<br> foreach($categories->items as $l) {<br> $_tmp = new View('item');<br> $_tmp->content = $l;<br> $_result.= $_tmp;<br> }<br> $this->template->content = $_result;<br> }<br>}

В методе index мы собираем массив главных категорий с объектами подкатегорий в нем и кол-вом объявлений(count) в подкатегориях и выводим это всё дело через вид category, который будет описан в главе «Виды».

Метод view показывает нам содержимое всех подкатегорий категории с id, переданным в параметре (как он туда попадёт — в главе «Окончательные настройки»). Также работает метод viewsub с разницей в том, что он применим для подкатегорий.

Теперь контроллер объявлений, items.php:
class Items_Controller extends Template_Controller {<br><br> public $template = 'index';<br><br> public function index() { // редирект на главную, если просто вызван контроллер<br> url::redirect('/index');<br> }<br><br> public function view($arg) { // отображение статьи с id = $arg<br> $_item = ORM::factory('item', $arg);<br> $this->template->content = new View('item');<br> $this->template->content->content = $_item;<br> }<br><br> public function edit($arg) { // правка статьи c id = $arg<br> $_tmp = ORM::factory('item', $arg);<br><br> if(Auth::instance()->logged_in('admin') || (Auth::instance()->get_user() &&<br> (Auth::instance()->get_user()->id == $_tmp->user_id))) { // продолжать только если пользователь автор или админ<br> $category = array();<br> // наполняем список категорий<br> foreach(ORM::factory('category')->where('parent_id', 0)->find_all() as $l) {<br> foreach($l->children as $n) {<br> $category[$l->name][$n->id] = $n->name;<br> }<br> }<br> // создадим класс для работы с формами<br> $form = new Forge(url::current());<br> $form->set_attr('method', 'post');<br> $form->input('title') // создаем тег <input id = 'title' /><br> ->label('Заголовок') // делаем для него <label for='title'>Заголовок</label><br> ->rules('required|length[3,40]') // правила валидации - обязательное, от 3 до 40 символов<br> ->value($_tmp->title); // и присваиваем ему значение из $_tmp->title<br> $form->textarea('addtext')<br> ->label('Текст')<br> ->rules('required')<br> ->value($_tmp->content);<br> $form->dropdown('category')<br> ->label('Категория')<br> ->options($category)<br> ->selected($_tmp->category_id);<br> $form->submit('Править');<br><br> // вот такая вот простая валидация формы<br> if ($form->validate()) {<br> // создаем и заполняем объект ORM<br> $new = ORM::factory('item', $arg);<br> $new->title = $form->inputs['title']->value;<br> $new->content = $form->inputs['addtext']->value;<br> $new->category_id = $form->inputs['category']->value;<br> // а после - сохраняем<br> $new->save();<br> url::redirect('/');<br> }<br> // отправка формы виду<br> $this->template->content = $form->render();<br> } else {<br> $this->template->content = "Вы не зарегистрированы";<br> }<br> }<br><br> public function add() { // добавление статьи<br> if(Auth::instance()->logged_in()) { // только для зарегистрированых пользователей<br> $category = array();<br> foreach(ORM::factory('category')->where('parent_id', 0)->find_all() as $l) {<br> foreach($l->children as $n) {<br> $category[$l->name][$n->id] = $n->name;<br> }<br> }<br> $form = new Forge(url::current());<br> $form->set_attr('method', 'post');<br> $form->input('title')<br> ->label('Заголовок')<br> ->rules('required|length[3,40]');<br> $form->textarea('addtext')<br> ->label('Текст')<br> ->rules('required');<br> $form->dropdown('category')<br> ->label('Категория')<br> ->options($category)<br> ->selected(0);<br> $form->submit('Добавить');<br> if ($form->validate()) {<br> $new = ORM::factory('item');<br> $new->title = $form->inputs['title']->value;<br> $new->content = $form->inputs['addtext']->value;<br> $new->category_id = $form->inputs['category']->value;<br> // вот так инстанция класса Auth дает доступ к пользовательским данным<br> $new->user_id = Auth::instance()->get_user()->id;<br> $new->datepub = time();<br> $new->save();<br> url::redirect('/');<br> }<br> $this->template->content = $form->render();<br> } else {<br> $this->template->content = "Вы не зарегистрированы";<br> }<br> }<br><br> public function delete($arg) { // удаление статьи<br> $new = ORM::factory('item', $arg);<br> if(Auth::instance()->logged_in('admin') || (Auth::instance()->get_user() &&<br> (Auth::instance()->get_user()->id == $new->user_id))) { // только автор или админ<br> $new->delete();<br> url::redirect('/');<br> } else {<br> $this->template->content = "Вы не зарегистрированы";<br> }<br> }<br>}

Думаю, единственный, до этого времени не встречавшийся нам класс — это Forge. Он используется для облегчения работы с формами, но, к сожалению, он исключен из стандартной поставки Kohana. Где его взять, я расскажу ниже.

Ну и контроллер пользователей, user.php:
class User_Controller extends Template_Controller {<br><br> public $template = 'index';<br><br> public function login() { // вход<br> if (Auth::instance()->logged_in()) { // если не зарегистрированы, то на главную<br> url::redirect('/');<br> } else { // создание формы<br> $form = new Forge;<br> $form->set_attr('method', 'post');<br> $form->input('username')<br> ->label('Логин')<br> ->rules('required|length[4,32]');<br> $form->password('password')<br> ->label('Пароль')<br> ->rules('required|length[4,40]');<br> $form->submit('Войти');<br> if ($form->validate()) {<br> $user = ORM::factory('user', $form->username->value);<br> // если вход успешен, то на главную<br> if (Auth::instance()->login($user, $form->password->value)) {<br> url::redirect('/');<br> } else {<br> // если нет, то вывести ошибку<br> $form->password->add_error('login_failed', 'Неверное имя пользователя, или пароль.');<br> }<br> }<br> }<br> $this->template->content = $form->render();<br> }<br><br> public function logout() { // выход<br> if(Auth::instance()->logged_in()) {<br> Auth::instance()->logout(TRUE);<br> }<br> url::redirect('/');<br> }<br><br> public function register() { // регистрация<br> if(Auth::instance()->logged_in()) {<br> url::redirect('/');<br> } else {<br> $form = new Forge(url::current(), 'Регистрация');<br> $form->set_attr('method', 'post');<br> $form->input('username')<br> ->label('Логин')<br> // допускается только пароль из латиницы, с цифрами знаками '_' и '-' от 4 до 32 символов<br> ->rules('required|length[4,32]|valid_alpha_dash');<br> $form->password('password')->label('Пароль');<br> $form->password('password2')<br> ->label('Опять пароль')<br> ->rules('required|length[6,40]|valid_alpha_dash')<br> // это поле должно совпадать с полем 'password'<br> ->matches($form->password);<br> $form->input('email')<br> ->label('E-Mail')<br> ->rules('required|valid_email');<br> $form->submit('Регистрация');<br> if ($form->validate()) {<br> $user = ORM::factory('user', $form->username->value);<br> // проверяем, нет ли уже такого пользователя<br> if (!$user->username_exists($form->username->value)) {<br> $user->username = $form->username->value;<br> $user->password = $form->password->value;<br> $user->email = $form->email->value;<br> // добавляем пользователя и ставим для него права<br> if($user->save() && $user->add(ORM::factory('role', 'login'))) {<br> Auth::instance()->login($user, $form->password->value);<br> url::redirect('/');<br> }<br> }<br> }<br> }<br> $this->template->content = $form->render();<br> }<br>}

Интересный момент при создании формы регистрации, а именно метод matches() объекта, который возвращает метод Forge password. При обращении к нему, параметром нужно указать другой объект password, при несоответствии значений которых скрипт выдаст ошибку.

Виды


Виды (представления), отвечают за отображение информации и являются конечной прослойкой между пользователем и приложением.
Теперь немного о видах. Всего в нашем движке мы использовали три вида, поочередно о каждом:

category.php:
<?php foreach($cats as $content): ?><br> <div class="category"><br> <h3><a href="/category/<?php echo $content->id ?>"><?php echo $content->name ?></a>/h3><br> <ul><br> <?php foreach($content->children as $l): ?><br> <li><a href="/subcategory/<?php echo $l->id ?>"><?php echo $l->name ?></a></li><br> <?php endforeach; ?><br> </ul><br> </div><br><?php endforeach; ?>

Как Вы помните, мы ему передавали массив с категориями и подкатегориями, который он и обрабатывает, в цикле выводя их все.

index.php:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><br><html xmlns="http://www.w3.org/1999/xhtml"><br><head><br> <title>Kohana</title><br></head><br><body><br> <div id="enter" style="float:right"><br> <?php if(Auth::instance()->logged_in()) { ?><br> Привет, <b><?php echo Auth::instance()->get_user()->username ?></b><a href="/logout">Выход</a><br> <?php } else { ?><br> <a href="/login">Войти</a><a href="/register">Регистрация</a><br> <?php } ?><br> </div><br> <div id='links'><br> <a href="/">Главная</a><br> <a href="/add">Добавить</a><br> </div><br> <div id='content'><br> <?php echo $content ?><br> </div><br></body><br></html>

Это «обвертка» для всех страниц, которые мы отображаем. В блоке enter у нас стоит такой-себе триггер, который, в зависимости от статуса пользователя отображает или его имя со ссылкой на выход, или ссылки на вход и регистрацию.

item.php:
<div class="item"><br> <?php if(Auth::instance()->logged_in('admin') || (Auth::instance()->get_user() &&<br> Auth::instance()->get_user()->id == $content->user_id)) { ?><br> <span style="float:right"><br> <a href="/items/edit/<?php echo $content->id ?>">Править</a><br> <a href="/items/delete/<?php echo $content->id ?>">Удалить</a><br> </span><br> <?php } ?><br> <h1><?php echo html::anchor('/' . $content->id, html::specialchars($content->title)) ?></h1><br> <div class="other">Опубликовано <?php echo date("j-M-Y ", $content->datepub) ?><br> пользователем <b><?php echo html::specialchars($content->>user->username) ?></b><br> </div><br> <div class="news"><br> <?php<br> echo text::auto_p($content->content);<br> ?><br> </div><br></div>

Этот вид показывает нам, само объявление, выводя ссылки на удаление и редактирование в случае, если пользователь админ или автор объявления. Также можно заметить использование двух хелперов — html и text. html::specialchars делает строку «безопасной», html::anchor делает ссылку — первый параметр адрес, второй — текст, ну а text::auto_p автоматически добавляет абзацы к plain text, как сказано в офф. документации «nl2br() on steroids».

Окончательные настройки


Теперь немного по-настраиваем нашу «систему» перед запуском.

Создайте в папке /application/config/ файл database.php и пропишите в нем следуещее:
$config['default']['connection'] = array(
'type' => 'mysql',
'user' => 'пользователь',
'pass' => 'пароль',
'host' => 'сервер',
'database' => 'имя базы',
);

Это, как Вы поняли, настройки для MySQL.

Далее, откройте файл config.php в той же папке и приведите массив $config['modules'] к виду
$config['modules'] = array
(
MODPATH.'auth',
MODPATH.'forge',
);

Также следует изменить $config['site_domain'] на '/', если директории Kohana лежат у Вас в корне сайта. И настоятельно рекомендую установить значение $config['index_page'] в пустую строку, в ином случае, Kohana будет генерировать относительные ссылки с вставкой /index.php/ в URL (прим.: site.com/index.php/mail).

А теперь создайте файл routes.php и пропишите в нем
$config['_default'] = 'category';
$config['category/([0-9]+)'] = 'category/view/$1';
$config['subcategory/([0-9]+)'] = 'category/viewsub/$1';
$config['add'] = 'items/add';
$config['items/([0-9]+)'] = 'items/view/$1';
$config['edit/([0-9]+)'] = 'items/edit/$1';
$config['delete/([0-9]+)'] = 'items/delete/$1';
$config['login'] = 'user/login';
$config['logout'] = 'user/logout';
$config['register'] = 'user/register';
$co
Теги:kohanaphpmvcormначинающим
Хабы: Kohana
Всего голосов 73: ↑62 и ↓11+51
Просмотры15K

Похожие публикации

Лучшие публикации за сутки