diff --git a/application/classes/actions/ActionBlog.class.php b/application/classes/actions/ActionBlog.class.php index 89f82ec8..6e1e18c0 100644 --- a/application/classes/actions/ActionBlog.class.php +++ b/application/classes/actions/ActionBlog.class.php @@ -116,6 +116,7 @@ class ActionBlog extends Action 'ajaxbloginfo', 'ajaxblogjoin', 'ajax', + '_show_topic_url', ); /** @@ -184,8 +185,8 @@ class ActionBlog extends Action $this->AddEventPreg('/^ajax$/i', '/^remove-avatar$/i', '/^$/i', 'EventAjaxRemoveAvatar'); $this->AddEventPreg('/^ajax$/i', '/^modal-crop-avatar$/i', '/^$/i', 'EventAjaxModalCropAvatar'); + $this->AddEventPreg('/^_show_topic_url$/i', '/^$/i', 'EventInternalShowTopicByUrl'); $this->AddEventPreg('/^(\d+)\.html$/i', '/^$/i', array('EventShowTopic', 'topic')); - $this->AddEventPreg('/^[\w\-\_]+$/i', '/^(\d+)\.html$/i', array('EventShowTopic', 'topic')); $this->AddEventPreg('/^[\w\-\_]+$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventShowBlog', 'blog')); $this->AddEventPreg('/^[\w\-\_]+$/i', '/^bad$/i', '/^(page([1-9]\d{0,5}))?$/i', array('EventShowBlog', 'blog')); @@ -714,23 +715,85 @@ class ActionBlog extends Action $this->SetTemplateAction('index'); } + + /** + * Обработка ЧПУ топика + */ + protected function EventInternalShowTopicByUrl() + { + $sTopicUrl = Config::Get('module.topic._router_topic_original_url'); + $sSecurityHash = Config::Get('module.topic._router_topic_security_hash'); + /** + * Проверяем ключ + */ + if ($sSecurityHash != Config::Get('module.security.hash')) { + return $this->EventErrorDebug(); + } + /** + * Проверяем корректность URL топика + * Сначала нужно получить сам топик по ID или уникальному полю Slug (транслитерированный заголовок) + * Смотрим наличие ID или Slug в маске топика + */ + $sUrlEscape = preg_quote(trim(Config::Get('module.topic.url'), '/ ')); + $aMask = array_map(function ($sItem) { + return "({$sItem})"; + }, Config::Get('module.topic.url_preg')); + $sPreg = strtr($sUrlEscape, $aMask); + if (preg_match('@^' . $sPreg . '$@iu', $sTopicUrl, $aMatch)) { + $aRuleRequire = array(); + if (preg_match_all('#%(\w+)%#', $sUrlEscape, $aMatch2)) { + foreach ($aMatch2[1] as $k => $sFind) { + if (in_array($sFind, array('id', 'title'))) { + if (isset($aMatch[$k + 1])) { + $aRuleRequire[$sFind] = $aMatch[$k + 1]; + } + } + } + } + /** + * Не удалось найти обязательные поля - запускаем обработку дальше по цепочке + */ + if (!$aRuleRequire) { + return Router::Action($sTopicUrl); + } + + $oTopic = null; + /** + * Ищем топик + */ + if (isset($aRuleRequire['id'])) { + $oTopic = $this->Topic_GetTopicById($aRuleRequire['id']); + } elseif (isset($aRuleRequire['title'])) { + $oTopic = $this->Topic_GetTopicBySlug($aRuleRequire['title']); + } + if (!$oTopic) { + return Router::Action($sTopicUrl); + } + /** + * Проверяем корректность URL топика + */ + if ($oTopic->getUrl(false) != $sTopicUrl) { + Router::Location($oTopic->getUrl()); + } + /** + * Направляем на стандартную обработку топика + */ + return Router::Action('blog', "{$oTopic->getId()}.html"); + } + /** + * Запускаем обработку дальше по цепочке + */ + return Router::Action($sTopicUrl); + } + /** * Показ топика * */ protected function EventShowTopic() { - $sBlogUrl = ''; - if ($this->GetParamEventMatch(0, 1)) { - // из коллективного блога - $sBlogUrl = $this->sCurrentEvent; - $iTopicId = $this->GetParamEventMatch(0, 1); - $this->sMenuItemSelect = 'blog'; - } else { - // из персонального блога - $iTopicId = $this->GetEventMatch(1); - $this->sMenuItemSelect = 'log'; - } + $iTopicId = $this->GetEventMatch(1); + $this->sMenuItemSelect = 'blog'; $this->sMenuSubItemSelect = ''; /** * Проверяем есть ли такой топик @@ -744,24 +807,6 @@ class ActionBlog extends Action if (!$this->ACL_IsAllowShowTopic($oTopic, $this->oUserCurrent)) { return parent::EventNotFound(); } - /** - * Если запросили топик из персонального блога то перенаправляем на страницу вывода коллективного топика - */ - if ($sBlogUrl != '' and $oTopic->getBlog()->getType() == 'personal') { - Router::Location($oTopic->getUrl()); - } - /** - * Если запросили не персональный топик то перенаправляем на страницу для вывода коллективного топика - */ - if ($sBlogUrl == '' and $oTopic->getBlog()->getType() != 'personal') { - Router::Location($oTopic->getUrl()); - } - /** - * Если номер топика правильный но УРЛ блога косяный то корректируем его и перенаправляем на нужный адрес - */ - if ($sBlogUrl != '' and $oTopic->getBlog()->getUrl() != $sBlogUrl) { - Router::Location($oTopic->getUrl()); - } /** * Достаём комменты к топику */ diff --git a/application/classes/modules/topic/Topic.class.php b/application/classes/modules/topic/Topic.class.php index 30d841a3..665d641c 100644 --- a/application/classes/modules/topic/Topic.class.php +++ b/application/classes/modules/topic/Topic.class.php @@ -479,6 +479,24 @@ class ModuleTopic extends Module return null; } + /** + * Получить топик по url/slug + * + * @param string $sSlug url/slug топика + * @return ModuleTopic_EntityTopic|null + */ + public function GetTopicBySlug($sSlug) + { + if (!is_scalar($sSlug)) { + return null; + } + $aTopics = $this->GetTopicsByFilter(array('topic_slug' => $sSlug), 1, 1); + if ($aTopics['collection']) { + return reset($aTopics['collection']); + } + return null; + } + /** * Получить список топиков по списку айдишников * @@ -1829,4 +1847,91 @@ class ModuleTopic extends Module $this->Cache_Clean(Zend_Cache::CLEANING_MODE_MATCHING_TAG, array("topic_update")); return $res; } + + /** + * Формирует и возвращает полный ЧПУ URL для топика + * + * @param ModuleTopic_EntityTopic $oTopic + * @param bool $bAbsolute При false вернет относительный УРЛ + * @return string + */ + public function BuildUrlForTopic($oTopic, $bAbsolute = true) + { + $sUrlMask = Config::Get('module.topic.url'); + $iDateCreate = strtotime($oTopic->getDateAdd()); + $aReplace = array( + '%year%' => date("Y", $iDateCreate), + '%month%' => date("m", $iDateCreate), + '%day%' => date("d", $iDateCreate), + '%hour%' => date("H", $iDateCreate), + '%minute%' => date("i", $iDateCreate), + '%second%' => date("s", $iDateCreate), + '%login%' => '', + '%blog%' => '', + '%id%' => $oTopic->getId(), + '%title%' => $oTopic->getSlug(), + '%type%' => $oTopic->getType(), + ); + + /** + * Получаем связанные данные только если в этом есть необходимость + */ + if (strpos($sUrlMask, '%blog%') !== false) { + if (!($oBlog = $oTopic->GetBlog())) { + $oBlog = $this->Blog_GetBlogById($oTopic->getBlogId()); + } + if ($oBlog) { + if ($oBlog->getType() == 'personal') { + $sUrlMask = str_replace('%blog%', '%login%', $sUrlMask); + } else { + $aReplace['%blog%'] = $oBlog->getUrl(); + } + } + } + + if (strpos($sUrlMask, '%login%') !== false) { + if (!($oUser = $oTopic->GetUser())) { + $oUser = $this->User_GetUserById($oTopic->getUserId()); + } + if ($oUser) { + $aReplace['%login%'] = $oUser->getLogin(); + } + } + $sUrl = strtr($sUrlMask, $aReplace); + return $bAbsolute ? Router::GetPathRootWeb() . '/' . $sUrl : $sUrl; + } + + /** + * Формирует из строки url + * + * @param string $sText + * @return string + */ + public function MakeSlug($sText) + { + return $this->Text_Transliteration($sText); + } + + /** + * Возвращает URL с учетом уникалькости по всем топикам + * + * @param string$sSlug + * @param int|null $iSkipTopicId + * @return string + */ + public function GetUniqueSlug($sSlug, $iSkipTopicId = null) + { + $iPostfix = 0; + do { + $oTopic = $this->GetTopicBySlug($sSlug . ($iPostfix ? '-' . $iPostfix : '')); + if ($oTopic and (is_null($iSkipTopicId) or $iSkipTopicId != $oTopic->getId())) { + $iPostfix++; + $bNeedNext = true; + } else { + $bNeedNext = false; + } + } while ($bNeedNext); + + return $sSlug . ($iPostfix ? '-' . $iPostfix : ''); + } } \ No newline at end of file diff --git a/application/classes/modules/topic/entity/Topic.entity.class.php b/application/classes/modules/topic/entity/Topic.entity.class.php index 24f4346f..794d228e 100644 --- a/application/classes/modules/topic/entity/Topic.entity.class.php +++ b/application/classes/modules/topic/entity/Topic.entity.class.php @@ -59,6 +59,12 @@ class ModuleTopic_EntityTopic extends Entity 'allowEmpty' => Config::Get('module.topic.title_allow_empty'), 'label' => $this->Lang_Get('topic.add.fields.title.label') ); + $this->aValidateRules[] = array( + 'topic_slug_raw', + 'regexp', + 'allowEmpty' => true, + 'pattern' => '#^[a-z0-9\-]{1,500}$#i' + ); $this->aValidateRules[] = array( 'topic_text_source', 'string', @@ -79,6 +85,7 @@ class ModuleTopic_EntityTopic extends Entity $this->aValidateRules[] = array('blogs_id_raw', 'blogs'); $this->aValidateRules[] = array('topic_text_source', 'topic_unique'); + $this->aValidateRules[] = array('topic_slug_raw', 'slug_check'); } /** @@ -124,6 +131,49 @@ class ModuleTopic_EntityTopic extends Entity return $this->Lang_Get('topic.add.notices.error_type'); } + /** + * Проверка URL топика + * + * @param string $sValue Проверяемое значение + * @param array $aParams Параметры + * @return bool|string + */ + public function ValidateSlugCheck($sValue, $aParams) + { + if (!$this->User_GetIsAdmin()) { + /** + * Простому пользователю разрешаем менять url только в течении X времени после создания топика + * Причем не прямую смену url, а через транлитерацию заголовка топика + */ + if ($this->getId()) { + if (strtotime($this->getDateAdd()) < time() - 60 * 60 * 1) { + /** + * Не меняем url + */ + return true; + } + } + /** + * Для нового топика всегда формируем url + */ + $this->setSlugRaw(''); + } + + if ($this->getSlugRaw()) { + $this->setSlug($this->Topic_GetUniqueSlug($this->getSlugRaw(), $this->getId())); + } elseif ($this->getTitle()) { + if ($sUrl = $this->Topic_MakeSlug($this->getTitle())) { + /** + * Получаем уникальный URL + */ + $this->setSlug($this->Topic_GetUniqueSlug($sUrl, $this->getId())); + } else { + return $this->Lang_Get('topic.add.notices.error_slug'); + } + } + return true; + } + /** * Проверка топика на уникальность * @@ -295,6 +345,16 @@ class ModuleTopic_EntityTopic extends Entity return $this->_getDataOne('topic_title'); } + /** + * Возвращает url топика + * + * @return string|null + */ + public function getSlug() + { + return $this->_getDataOne('topic_slug'); + } + /** * Возвращает текст топика * @@ -613,15 +673,12 @@ class ModuleTopic_EntityTopic extends Entity /** * Возвращает полный URL до топика * + * @param bool $bAbsolute При false вернет относительный УРЛ * @return string */ - public function getUrl() + public function getUrl($bAbsolute = true) { - if ($this->getBlog()->getType() == 'personal') { - return Router::GetPath('blog') . $this->getId() . '.html'; - } else { - return Router::GetPath('blog') . $this->getBlog()->getUrl() . '/' . $this->getId() . '.html'; - } + return $this->Topic_BuildUrlForTopic($this, $bAbsolute); } /** @@ -963,6 +1020,16 @@ class ModuleTopic_EntityTopic extends Entity $this->_aData['topic_title'] = $data; } + /** + * Устанавливает url топика + * + * @param string $data + */ + public function setSlug($data) + { + $this->_aData['topic_slug'] = $data; + } + /** * Устанавливает текст топика * diff --git a/application/classes/modules/topic/mapper/Topic.mapper.class.php b/application/classes/modules/topic/mapper/Topic.mapper.class.php index 4814954c..c878fd4c 100644 --- a/application/classes/modules/topic/mapper/Topic.mapper.class.php +++ b/application/classes/modules/topic/mapper/Topic.mapper.class.php @@ -44,6 +44,7 @@ class ModuleTopic_MapperTopic extends Mapper user_id, topic_type, topic_title, + topic_slug, topic_tags, topic_date_add, topic_user_ip, @@ -55,10 +56,10 @@ class ModuleTopic_MapperTopic extends Mapper topic_forbid_comment, topic_text_hash ) - VALUES(?d, ?d, ?d, ?d, ?d, ?d, ?, ?, ?, ?, ?, ?d, ?d, ?d, ?d, ?, ?, ?) + VALUES(?d, ?d, ?d, ?d, ?d, ?d, ?, ?, ?, ?, ?, ?, ?d, ?d, ?d, ?d, ?, ?, ?) "; if ($iId = $this->oDb->query($sql, $oTopic->getBlogId(), $oTopic->getBlogId2(), $oTopic->getBlogId3(), - $oTopic->getBlogId4(), $oTopic->getBlogId5(), $oTopic->getUserId(), $oTopic->getType(), $oTopic->getTitle(), + $oTopic->getBlogId4(), $oTopic->getBlogId5(), $oTopic->getUserId(), $oTopic->getType(), $oTopic->getTitle(), $oTopic->getSlug(), $oTopic->getTags(), $oTopic->getDateAdd(), $oTopic->getUserIp(), $oTopic->getPublish(), $oTopic->getPublishDraft(), $oTopic->getPublishIndex(), $oTopic->getSkipIndex(), $oTopic->getCutText(), $oTopic->getForbidComment(), $oTopic->getTextHash()) @@ -518,6 +519,7 @@ class ModuleTopic_MapperTopic extends Mapper blog_id4= ?d, blog_id5= ?d, topic_title= ?, + topic_slug= ?, topic_tags= ?, topic_date_add = ?, topic_date_edit = ?, @@ -542,7 +544,7 @@ class ModuleTopic_MapperTopic extends Mapper topic_id = ?d "; $res = $this->oDb->query($sql, $oTopic->getBlogId(), $oTopic->getBlogId2(), $oTopic->getBlogId3(), - $oTopic->getBlogId4(), $oTopic->getBlogId5(), $oTopic->getTitle(), $oTopic->getTags(), + $oTopic->getBlogId4(), $oTopic->getBlogId5(), $oTopic->getTitle(), $oTopic->getSlug(), $oTopic->getTags(), $oTopic->getDateAdd(), $oTopic->getDateEdit(), $oTopic->getDateEditContent(), $oTopic->getUserIp(), $oTopic->getPublish(), $oTopic->getPublishDraft(), $oTopic->getPublishIndex(), $oTopic->getSkipIndex(), $oTopic->getRating(), $oTopic->getCountVote(), $oTopic->getCountVoteUp(), $oTopic->getCountVoteDown(), @@ -590,6 +592,9 @@ class ModuleTopic_MapperTopic extends Mapper if (isset($aFilter['topic_date_more'])) { $sWhere .= " AND t.topic_date_add > " . $this->oDb->escape($aFilter['topic_date_more']); } + if (isset($aFilter['topic_slug'])) { + $sWhere .= " AND t.topic_slug = " . $this->oDb->escape($aFilter['topic_slug']); + } if (isset($aFilter['topic_publish'])) { $sWhere .= " AND t.topic_publish = " . (int)$aFilter['topic_publish']; } diff --git a/application/config/config.php b/application/config/config.php index 23ae7b47..2f05a941 100644 --- a/application/config/config.php +++ b/application/config/config.php @@ -149,6 +149,43 @@ $config['module']['topic']['default_period_discussed'] = 1; // Дефолтны $config['module']['topic']['max_blog_count'] = 3; // Количество блогов, которые можно задать топику. Максимальное значение 5. $config['module']['topic']['max_rss_count'] = 20; // Максимальное количество топиков в RSS потоке $config['module']['topic']['default_preview_size'] = '900x300crop'; // Дефолтный размер превью для топика (все размеры задаются в конфиге media), который будет использоваться, например, для Open Graph +/** + * Настройка ЧПУ URL топиков + * Допустимы шаблоны: + * %year% - год топика (2010) + * %month% - месяц (08) + * %day% - день (24) + * %hour% - час (17) + * %minute% - минуты (06) + * %second% - секунды (54) + * %login% - логин автора топика (admin) + * %blog% - url основного блога (report), если топик без блога, то этот параметр заменится на логин автора топика + * %id% - id топика (325) + * %title% - заголовок топика в транслите (moy_topic) + * %type% - тип топика (news) + * + * В шаблоне обязательно должен быть %id% или %title%, это необходимо для точной идентификации топика + */ +$config['module']['topic']['url'] = '%year%/%month%/%day%/%id%.html'; +/** + * Список регулярных выражений для частей URL топика + * Без необходмых навыков лучше этот параметр не менять + */ +$config['module']['topic']['url_preg'] = array( + '%year%' => '\d{4}', + '%month%' => '\d{2}', + '%day%' => '\d{2}', + '%hour%' => '\d{2}', + '%minute%' => '\d{2}', + '%second%' => '\d{2}', + '%login%' => '[\w\-\_]+', + '%blog%' => '[\w\-\_]+', + '%id%' => '\d+', + '%title%' => '[\w\-\_]+', + '%type%' => '[\w\-\_]+', +); + + // Модуль User $config['module']['user']['per_page'] = 15; // Число юзеров на страницу на странице статистики и в профиле пользователя $config['module']['user']['friend_on_profile'] = 15; // Ограничение на вывод числа друзей пользователя на странице его профиля diff --git a/application/frontend/components/topic/topic-add.tpl b/application/frontend/components/topic/topic-add.tpl index 94cd7000..1056fc4f 100644 --- a/application/frontend/components/topic/topic-add.tpl +++ b/application/frontend/components/topic/topic-add.tpl @@ -73,6 +73,15 @@ entity = 'ModuleTopic_EntityTopic' label = $aLang.topic.add.fields.title.label} + {* URL топика *} + {if $oUserCurrent->isAdministrator()} + {component 'field' template='text' + name = 'topic[topic_slug_raw]' + value = {(( $topic ) ? $topic->getSlug() : '')|escape} + note = {lang 'topic.add.fields.slug.note'} + label = {lang 'topic.add.fields.slug.label'}} + {/if} + {block 'add_topic_form_text_before'}{/block} diff --git a/application/frontend/i18n/ru.php b/application/frontend/i18n/ru.php index 01fede3b..8642543b 100644 --- a/application/frontend/i18n/ru.php +++ b/application/frontend/i18n/ru.php @@ -1080,6 +1080,10 @@ return array( 'title' => array( 'label' => 'Заголовок' ), + 'slug' => array( + 'label' => 'URL', + 'note' => 'Формируется автоматически из названия топика, но вы можете задать свое значение' + ), 'text' => array( 'label' => 'Текст' ), @@ -1114,6 +1118,7 @@ return array( 'error_blog_not_allowed' => 'Вы не можете писать в этот блог', 'error_text_unique' => 'Вы уже писали топик с таким содержанием', 'error_type' => 'Неверный тип топика', // TODO: Remove? + 'error_slug' => 'Необходимо указать URL топика', 'error_favourite_draft' => 'Топик из черновиков нельзя добавить в избранное', 'time_limit' => 'Вам нельзя создавать топики слишком часто', 'rating_limit' => 'Вам не хватает рейтинга для создания топика', diff --git a/application/install/data/sql/patch_1.0.3_to_2.0.0.sql b/application/install/data/sql/patch_1.0.3_to_2.0.0.sql index 69e4d14f..96ab1571 100644 --- a/application/install/data/sql/patch_1.0.3_to_2.0.0.sql +++ b/application/install/data/sql/patch_1.0.3_to_2.0.0.sql @@ -872,3 +872,6 @@ ALTER TABLE `prefix_invite_use` -- 07.03.2015 ALTER TABLE `prefix_user` CHANGE `user_referal_code` `user_referral_code` VARCHAR(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL; + +-- 17.03.2015 +ALTER TABLE `prefix_topic` ADD `topic_slug` VARCHAR(500) NOT NULL DEFAULT '' AFTER `topic_title`, ADD INDEX (`topic_slug`) ; diff --git a/bootstrap/start.php b/bootstrap/start.php index 171c5bba..f03a580f 100644 --- a/bootstrap/start.php +++ b/bootstrap/start.php @@ -61,15 +61,55 @@ require_once($sPathToFramework . '/config/loader.php'); /** * Определяем дополнительные параметры роутинга */ -$aRouterParams = array(/* - 'callback_after_parse_url' => function() { +$aRouterParams = array( + 'callback_after_parse_url' => array( + function () { + /** + * Логика по ЧПУ топиков + * Если URL соответствует шаблону ЧПУ топика, перенаправляем обработку на экшен/евент /blog/_show_topic_url/ + * Через свои параметры конфига передаем исходный URL и ключ из конфига 'module.security.hash', ключ нужен для проверки валидности запроса. + * Если ключ верный, то 100% это внутренняя обработка, а не произвольное внешнее обращение к URL + * Суть обработки _show_topic_url в том, чтобы определить ID топика и корректность его URL, если он некорректен, то произвести его корректировку через внешний редирект на правильный URL + * Если удалось определить топик и URL корректный, то происходит внутренний редирект на стандартный евент отображения топика по ID (/blog/12345.html) + */ - } - */ + $sUrlRequest = ''; + if (Router::GetAction()) { + $sUrlRequest .= Router::GetAction(); + } + if (Router::GetActionEvent()) { + $sUrlRequest .= '/' . Router::GetActionEvent(); + } + if (Router::GetParams()) { + $sUrlRequest .= '/' . join('/', Router::GetParams()); + } + /** + * Функция для формирования регулярного выражения по маске URL топика + * + * @param string $sUrl + * @return string + */ + $funcMakePreg = function ($sUrl) { + $sUrl = preg_quote(trim($sUrl, '/ ')); + return strtr($sUrl, Config::Get('module.topic.url_preg')); + }; + $sPreg = $funcMakePreg(Config::Get('module.topic.url')); + if (preg_match('@^' . $sPreg . '$@iu', $sUrlRequest)) { + Router::SetAction('blog'); + Router::SetActionEvent('_show_topic_url'); + Router::SetParams(array()); + /** + * Хак - через конфиг передаем нужные параметры в обработчик эвента + * Модуль кеша здесь нельзя использовать, т.к. еще не произошло инициализации ядра + */ + Config::Set('module.topic._router_topic_original_url', $sUrlRequest); + Config::Set('module.topic._router_topic_security_hash', Config::Get('module.security.hash')); + } + } + ) ); - /** * Проверяем наличие директории install */ diff --git a/framework b/framework index ea1a9ac4..ae7dfb5d 160000 --- a/framework +++ b/framework @@ -1 +1 @@ -Subproject commit ea1a9ac4c47041e76c8d2665dc0f95cc4f48dfe1 +Subproject commit ae7dfb5d8bb201af9937d7040bcbab4d2f23b8d3