Анализ кода плагина Cyr to Lat enhanced

Текст видео

Здравствуйте, друзья. Рубрика “Под капотом” и это первое её видео. Сегодня разберем код плагина Cyr to Lat enhanced - очень популярным плагином по транслитерации кириллистических и грузинских символов в урле в латинские тире английские. Да что тут рассказывать, скорее всего вы о нём знаете.

Этот плагин не обновлялся 4 года, но им продолжают пользоваться, так как он до сих пор выполняет свои задачи. Имеет высокий рейтинг, но наряду с положительными отзывами имеет несколько отрицательных, весьма не беспочвенных. В этом видео постараемся во всём разобраться… изнутри. Для этого открою главный файл плагина, он же единственный, и сверну весь код для удобства изучения его структуры.

Сразу в глаза бросается отсутствие проверки среды WordPress. Обычно проверяют наличие константы ABSPATH. Если обратиться к этому файлу напрямую и сервер настроен так, что показывает ошибки (так не должно быть, но всё же), то сервер упадет с 500 ошибкой, так как интерпретатор php не нашёл функцию add_filter() или другую функцию WordPress, входящую в состав плагина. В этой ошибке будет указать путь, где хранится файл плагина, а это небезопасно.

Смотрим дальше. Плагин начинает работать по сути с функции register_activation_hook(), которая срабатывает во время активации плагина и запускает функцию, указанную во втором параметре, в нашем случае функцию ctl_schedule_conversion().

Заглянем в эту функцию. А здесь на хуке shutdown вызывается еще одна функция ctl_convert_existing_slugs(). Возникает вопрос, зачем создана эта прокладка в виде хука, неужели нельзя было напрямую вызвать функцию ctl_convert_existing_slugs() при активации плагина? Можно, конечно, если бы эта функция выполнялась быстро и потенциально без ошибок. Но она может выполняться продолжительное время и это может привести к фатальной ошибке сервера. Допустим, функция выполняется 40 секунд. А у каждого сервера есть лимит на выполнение php скрипта, как правило это 30 секунд. Что произойдет, если этот лимит будет превышен? Правильно, сервер остановит выполнение php скрипта на 30 секунде, выдаст 500 ошибку, а плагин, естественно, не активируется. Но что произойдет, если запустить эту функцию через хук shutdown? Этот хук срабатывает в момент, когда PHP скрипт завершил все свои операции. Получается, плагин активируется, то есть отрабатывает весь дефолтный функционал WordPress и только в самом конце вызывается наша функция, которой снова дается 30 условных секунд на выполнение. И тут уже не важно, успеет она отработать или нет, с ошибками или без - плагин уже активирован и готов к работе.

Давайте теперь посмотрим, что из себя представляет функция ctl_convert_existing_slugs() и почему из-за неё такой сыр-бор. Итак, обращаемся к глобальной переменной с объектом класса для работы с базой данных. Видим, код функции разделен на 2 логические части. Судя по названиям переменных 1 часть работает с записями, а 2 с терминами.

Начнем с 1 части. С помощью метода get_results() мы делаем запрос, прочтем его: выбрать значения из колонок с именами ID и post_name из таблицы с постами, где значения из колонки post_name (именно на основе значений этой колонки формируется ссылка поста) должны удовлетворять следующему регулярному выражению, а именно в нем должен содержаться хотя бы один символ, не являющейся буквой английского алфавита в любом регистре, цифрой и знака “дефис”. Получается, что в эту выборку не попадут посты, которые уже прошли транслитерацию и обрабатывать их уже нет смысла. Но это не все условия запроса. На ряду с условием с участием регулярки значения колонки post_status (статус поста) должно быть из следующих вариантов - publish (опубликованные), future (запланированные) и private (личные). Подчеркну, метод get_results() вернет посты, удовлетворяющие сразу двум условиям.

Итак, мы сделали запрос и получили массив с записями, которые еще не прошли транслитерацию. Этот массив будет состоять из объектов записей, у которых будет всего два свойства - ID и post_name. По контексту напрашивается все эти записи транслитерировать, что и делается в следующей строчке. Значение свойства post_name передаем в функцию ctl_sanitize_title(), предварительно пропустив через функцию urldecode(). Зачем? Взглянем, какой post_name у записи, который был сформирован на основе ее заголовка с кириллическими символами (показываю бд). Именно urldecode() вернёт ей прежний вид, а дальше уже дело за функцией ctl_sanitize_title(), которую рассмотрим чуть позже, чтобы не отвлекаться, просто сейчас держим в уме, что она транслитерирует переданную строку. Теперь полученный результат сравним с тем, что был до транслитерации. Если изменения были, то делаем две вещи. Обновляем значение post_name у поста, теперь он будет открываться по новому транслетерируемому урлу. А также в метополе _wp_old_slug помещаем старое значение, благодаря этому, когда пользователь зайдет по старому урлу, движок перенаправит его на новый урл и отдаст 301 статус, в общем для сео говорят полезно. Это в теории, в реальности же из-за вмешательства самого же плагина в функционал движка перенаправления не происходит, этот момент разберем попозже, когда изучим работу плагина лучше. Подытог: первой частью кода находим посты с нетранслитерированными урлами и делаем это.

Переходим ко второй части и тут делаем тоже самое, только с терминами. Единственное значимое отличие - у терминов нет метаполя и, соответственно, функционала хранить старый урл и делать перенаправление тоже.

Итак. Мы узнали, что делает функция ctl_convert_existing_slugs(), которая запускается после активации плагина - транслитерирует урлы у постов и терминов, что есть на сайте одним махом. И именно это свойство плагина отразилось в негативных отзывах. Людям не понравилось, что плагин затронул урлы старых постов и терминов. Отсюда вывод, если вы создаете новый сайт, то волноваться не о чем, но если у вас уже есть посты и термины, то знайте - плагин при активации изменит их урлы, впрочем оригиналы у постов будут сохранены в метаполе. Также мы видим, что функция активно работает с базой данных, получая и сохраняя данные. Если на сайте было много постов и терминов, то перебор и обновление данных может занять продолжительное время, вот почему ее выполнение повесили на хук shutdown. Кстати, а вы в разработке пользуетесь этим хуком?

Идём дальше. Мы уже столкнулись с функцией ctl_sanitize_title(), предлагаю теперь и ее посмотреть, но прежде обратите внимание, что она также дополнительно вызывается на хук-фильтрах sanitize_title и sanitize_file_name. Фильтр sanitize_title срабатывает в одноименной функции, которая очищает переданную строку от недопустимых символов. Обратите внимание, что приоритет выставлен на 9, когда по умолчанию он 10, то есть функция плагина будет отрабатывать в первую очередь, а потом все остальные. Несмотря на название, ее часто используют для очистки slug’ов. Получается, когда мы сохраняем пост или термин, значение slug, которое затем используется при построении урла, очищается с помощью этой функции, тем самым задействуется данный фильтр и значение slug также проходит через функцию ctl_sanitize_title(), транслетируруя его. Тоже самое происходит и на фильтре sanitize_file_name, только это относится к файлам - при сохранении имя файла транслитерируется тоже.

Теперь, когда вроде как всю логику работы плагина разрулили, дело остаётся за малым - разобраться, как же происходит сам процесс транслитерации строки в функции ctl_sanitize_title(). Обращаемся к глобальной переменной с экземпляром класса по работе с бд, тут всё понятно. Далее идет массив с кириллическими символами и их аналогами из символов английского алфавита. Также имеется подобный массив, только с грузинским алфавитом. Объединяем массивы в один. Получаем локаль сайта и на ее основе делаем небольшие корректировки в исходном массиве.

Теперь с помощью функции debug_backtrace() получаем стек вызовов функций в виде массива. Если в этом стеке присутствует название функции wp_insert_term, значит функция sanitize_title() была вызвана при создании термина. В результате этой проверки в переменной $is_term будет или true - создаем термин, или false - в противном случае.

Если $is_term равно true, то делаем запрос на основе переданной строки, используя ее как заголовок термина и пытаемся получить slug термина. Сакральный смысл этого запроса я так и не понял. А вы? Обратите внимание, что переменная $title передается в sql запрос как есть, что по сути является потенциальной sql-инъекцией. В таких вещах используйте метод prepare() или функцию esc_sql(), чтобы избежать проблем. В результате этой конструкции в переменной $term мы получаем или slug термина или пустоту. Если slug термина есть, то без каких либо преобразований возвращаем его. Но если он отсутствует, то переданную строку нужно транслитерировать.

Это делается за несколько шагов. Первый шаг - самый главный. С помощь функции strtr() мы заменяем каждый символ переданной строки в соответствии с нашим массивом соответствий. Правда массив мы перед этим пропускаем через фильтр ctl_table, чтобы у нас была возможность извне изменить данный массив на основе своих нужд. К примеру, дополнить массив китайскими иероглифами.

Далее, если в системе поддерживается функция iconv(), то преобразуем переданную строку из кодировки UTF-8 в такую же, но со специальными, скажем так, флагами TRANSLIT и IGNORE. Строка TRANSLIT включает режим транслитерации. Это значит, что в случае, если символ не может быть представлен в требуемой кодировке, он будет заменен на один или несколько наиболее близких по внешнему виду символов. Если добавить строку IGNORE, то символы, которые не могут быть представлены в требуемой кодировке, будут удалены.

Далее идут мелкие, но не менее важные преобразования. С помощью preg_replace() и описанному регулярному выражению заменяем все символы в переданной строке, которые не являются английскими буквами любого регистра, нижним подчеркиванием, дефисом и точкой на знак дефиса. Затем тем же методом избавляемся от повторно идущих знаков дефис. Затем убираем дефис с начала строки, если таковой есть, а затем и с конца. Всё, транслитерация строки завершена. Под строкой, как правило, я подразумевал slug поста или термина, то есть часть ссылки.

Что же, друзья, весь функционал плагина мы вычитали и теперь можем поговорить о баге с перенаправлением, о котором я упоминал. Мы знаем как транслитерируется строка, а также знаем, что это происходит на хуке sanitize_title. И в этом вся проблема! Когда вы пытаетесь зайти на пост по старому урлу, движок по идее должен выдать 404 ошибку, но прежде он пытается с помощью функции wp_old_slug_redirect() найти пост, у которого возможно когда-то был такой урл и перенаправить туда с 301 ответом сервера. За нахождение id такого поста с таким вот “старым” урлом отвечает функция _find_post_by_old_slug() или _find_post_by_old_date(). Заглянем в первую. И видим запрос, который ищет пост, у которого есть метаполе с ключом _wp_old_slug и значением, которое берется из переменных запроса с помощью функции get_query_var(). Указано name, что по сути есть slug записи. Давайте завардампим это дело. Сейчас плагин отключен. Пробую зайти на пост со старым урлом. Вот такой slug у записи. Посмотрим, что хранится в метаполе - а в нем тоже самое. Уберу свой код, перезагружаю страницу и происходит перенаправление на эту же запись, но с новым урлом. А теперь включу плагин и сделаю тоже самое. И видим, что закодированный slug испорчен, исчезли знаки процента. Перенаправления тоже нет, ведь по такому slug естественно ни один пост теперь не находится. В какой же момент плагин всё портит? В момент создания основного запроса, когда параметр name передается функции sanitize_title_for_query(), которая в свою очередь передает строку уже известной нам функции sanitize_title(), а в ней что? Правильно, хук sanitize_title и тут плагин вступает в работу и избавляется от знаков процента. Получается, вроде плагин предусмотрел сохранение старого slug, но в тоже время не дает им воспользоваться. Такие дела.

Итак, друзья, что мы извлекли из чтения кода плагина Cyr to Lat enhanced, что мы узнали?
Если плагин ставится на сайт, у которого уже есть посты и термины, то он изменит их ссылки при активации, а это значит для поисковиков это будут новые страницы. Благо для постов при изменении ссылки будет автоматически проставлен 301 редирект, но у терминов такого функционала нет вовсе.
И это есть поправка к 1 пункту - плагин вмешивается в работу движка при создании основного запроса, что ломает функционал перенаправления со старого урла записи на новый, если тот был на кириллице или состоял из других неугодных плагину символов. Чтобы перенаправления заработали, надо деактивировать плагин, но тогда транслитерация новых урлов отключится. Замкнутый квадрат.
Плагин вносит изменения в базу данных безвозвратно. Да, старые версии slug’ов записей он сохраняет, но обычному пользователю от этого нет никакого прока. Если вам не понравится, как отработал плагин, остаётся надеяться лишь на бэкапы, вы ведь периодически их делаете, правда?
Плагин имеет потенциальную SQL-инъекцию. Шанс, что ею как-то воспользуются низок, но всё же.
У плагина имеется хук ctl_table, позволяющий изменить массив с набором символов для транслитерации, не редактируя код самого плагина.
Подсмотрели как вообще в целом выглядит процесс транслитерации, все аналогичные плагины работают плюс минус одинаково.
Если вам нужно транслитерировать что-либо ещё, то просто пропускаете строку через функцию sanitize_title(), а, как мы знаем, плагин, благодаря хуку в этой функции, сделает свое дело.

Конец списка. От себя добавлю, что если бы не подготовка к этому видео, я так бы и продолжал использовать данный плагин, но теперь серьезно задумался его везде сменить. Знаете достойные аналоги? Пожалуйста, посоветуйте. Заодно и их разберем. Также хотелось бы добавить, что подобного рода плагины желательно выбирать для сайта один раз, ведь каждый плагин может иметь свое понимание, как транслитерировать урлы. Сменили плагин и тот поменял их по своему разумению. Хотя, конечно, всё зависит от реализации конкретного плагина.

На этом всё. Если у вас есть замечания и дополнения по видео - добро пожаловать в комментарии, подискутируем. А может у вас есть предложения по подобному роду материалов - пишите, не стесняйтесь. Если видео вам понравилось, то не забывайте поддержать канал лайком и другими общепринятыми средствами, всё в описании к видео. С вами был Дмитрий. Берегите себя и свои сайты. Пока!