Напишем поле для пометки неактуальных записей

+17
734
Напишем поле для пометки неактуальных записей

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

План такой: автор объявления видит кнопку с произвольным текстом, указываемым в настройках поля. При нажатии на эту кнопку автор выбирает из выпадающего списка причину, по которой объявление больше неактуально. Смена статуса должно происходить без перезагрузки страницы. Начнем.

Любое поле в InstantCMS состоит из 2-х обязательных файлов — файла поля в папке system/fields и файла шаблона в папке templates/шаблон/assets/fields. Начнем со второго.

Наше новое поле будет называться changestatus. По задумке, автор не должен иметь возможности менять статус при добавлении или редактировании записи, поэтому просто создадим пустой файл в папке templates/default/assets/fields с именем changestatus.tpl.php. В этом файле обычно пишется код, который выводит поле в форме. Но нам это не надо, поэтому создаем и закрываем — больше мы к нему возвращаться не будем. Почему файл создан в шаблоне default? Можно создать и в своем шаблоне, но если создать в дефолтном, то он будет работать в любом другом.

Теперь создадим в папке system/fields файл с именем changestatus.php. Здесь будет вся логика. Начнем с объявления класса fieldChangestatus, наследуемого от cmsFormField:

  1. <?php class fieldChangestatus extends cmsFormField {
  2.  
  3. }

Внутри класса напишем свойства поля: название поля, sql, тип в фильтре.

  1. <?php class fieldChangestatus extends cmsFormField {
  2.  
  3. public $title = 'Смена статуса';
  4. public $sql = "VARCHAR(128) NOT NULL DEFAULT 'actual'";
  5. public $filter_type = 'str';
  6.  
  7. }

Добавим опции. Всего их будет 3: кнопка отключения, текст, если объявление неактуально, и список причин, почему автор делает объявление неактуальным.

  1. <?php class fieldChangestatus extends cmsFormField {
  2.  
  3. public $title = 'Смена статуса';
  4. public $sql = "VARCHAR(128) NOT NULL DEFAULT 'actual'";
  5. public $filter_type = 'str';
  6.  
  7. public function getOptions(){
  8.  
  9. return [
  10. new fieldString('btn_off', [
  11. 'title' => 'Текст кнопки отключения',
  12. 'default' => 'Неактуально'
  13. ]),
  14. new fieldText('reason', [
  15. 'title' => 'Причины',
  16. 'hint' => 'Оставьте поле пустым, если указывать причину не нужно. По одной причине в каждой строке, например:<br><b>Продал на этом сайте<br>Продал на другом сайте<br>Передумал продавать</b>'
  17. ]),
  18. new fieldString('not_actual', [
  19. 'title' => 'Текст, если запись неактуальная',
  20. 'default' => 'Неактуально'
  21. ])
  22. ];
  23.  
  24. }
  25.  
  26. }

Теперь мы можем добавить наше новое поле в типе контента. Если всё сделано, как написано выше, то при добавлении мы получим нашу форму. Можно ее сразу заполнить и сохранить.

Изображение

Теперь нам надо вывести поле в записи и списке. За показ в поля в записи отвечает метод parse(), а за вывод в списке записей parseTeaser(), но у нас поле будет выводиться одинаково, поэтому добавим только parse():

  1. public function parse($value){
  2.  
  3. return $value;
  4.  
  5. }

Должно получиться так:

  1. <?php class fieldChangestatus extends cmsFormField {
  2.  
  3. public $title = 'Смена статуса';
  4. public $sql = "VARCHAR(128) NOT NULL DEFAULT 'actual'";
  5. public $filter_type = 'str';
  6.  
  7. public function getOptions(){
  8.  
  9. return [
  10. new fieldString('btn_off', [
  11. 'title' => 'Текст кнопки отключения',
  12. 'default' => 'Неактуально'
  13. ]),
  14. new fieldText('reason', [
  15. 'title' => 'Причины',
  16. 'hint' => 'Оставьте поле пустым, если указывать причину не нужно. По одной причине в каждой строке, например:<br><b>Продал на этом сайте<br>Продал на другом сайте<br>Передумал продавать</b>'
  17. ]),
  18. new fieldString('not_actual', [
  19. 'title' => 'Текст, если запись неактуальная',
  20. 'default' => 'Неактуально'
  21. ])
  22. ];
  23.  
  24. }
  25.  
  26. // Ниже добавлен метод parse()
  27. public function parse($value){
  28.  
  29. return $value;
  30.  
  31. }
  32.  
  33. }

Дальше будем работать внутри этого метода. Здесь просто показал, куда его вставлять. Пока мы вернули значение поля. Если посмотреть в записи, то сейчас для всех записей мы будем видеть текст «actual».

А теперь займемся логикой. Во-первых, что мы должны выводить и для кого? Если объявление актуальное, то для автора и админа нам надо показать кнопку, а для всех остальных не показывать ничего. Если неактуальное, то показать какой-то текст. Сначала надо узнать, кто смотрит объявление. Получаем пользователя:

  1. public function parse($value){
  2.  
  3. $user = cmsUser::getInstance();
  4.  
  5. return $value;
  6.  
  7. }

Пишем условие, если объявление актуальное, внутри условия пока вернем текст «Актуально»:

  1. public function parse($value){
  2.  
  3. $user = cmsUser::getInstance();
  4.  
  5. if ($value == 'actual') {
  6.  
  7. return 'Актуально';
  8.  
  9. }
  10.  
  11. return '';
  12.  
  13. }

Смотрим, что получилось:

Изображение

Теперь добавим внутри этого условия еще одно: если пользователь автор или админ, то покажем текст «Кнопка», а иначе не будем показывать ничего:

  1. if ($value == 'actual') {
  2.  
  3. if ($user->is_admin || $user->id == $this->item['user_id']) {
  4.  
  5. return 'Кнопка';
  6.  
  7. }
  8.  
  9. return '';
  10.  
  11. }

Здесь:

  • $user->is_admin — пользователь админ,
  • $user->id — id пользователя,
  • $this->item['user_id'] — id пользователя из записи.

Получается: если (if) админ или id пользователя равно id автора, то...

Вы могли заметить, что я не писал выражение else, потому что return прерывает выполнение скрипта.

Давайте теперь напишем саму кнопку.

  1. public function parse($value){
  2.  
  3. $user = cmsUser::getInstance();
  4.  
  5. $cl = $this->item['ctype_name'].'_'.$this->item['id'].'_'.$this->name; // Объявляем переменную $cl
  6.  
  7. if ($value == 'actual') {
  8.  
  9. if ($user->is_admin || $user->id == $this->item['user_id']) {
  10.  
  11. $off_text = $this->options['btn_off'] ? $this->options['btn_off'] : 'Больше неактуально?';
  12.  
  13. $btn_off = '<div class="changestatus_btn changestatus_off changestatus_off_'.$cl.'">'.$off_text.'</div>';
  14.  
  15. return $btn_off;
  16.  
  17. }
  18.  
  19. return '';
  20.  
  21. }
  22.  
  23. return '';
  24.  
  25. }

Здесь мы добавили div с тремя классами: 

  • changestatus_btn — класс для всех кнопок (будет же еще кнопка восстановления), скоро добавим для этого класса стили,
  • changestatus_off — отдельный класс для кнопки отключения,
  • changestatus_off_'.$cl.' — здесь мы сгенерировали класс для кнопки, соответствующей определенной записи.

Переменную $cl я объявил чуть выше. Если так не сделать, то потом, когда мы добавим javascript для этой кнопки, то в списке записей этот скрипт будет применяться только к кнопке первого объявления. На выходе этот класс будет иметь вид примерно такой: changestatus_off_board_14_status.

Перед этим мы получили объявили переменную $off_text, которая содержит текст кнопки из опций поля. Если текст в опциях не указан, то выводится текст «Больше неактуально?». 

Вот такая получилась кнопка:

Изображение

На кнопку не очень похоже, да? Добавим стилей. Для этого в папке templates/default/css создадим файл field-changestatus.css и подключим его внутри условия, где определяли пользователя:

  1. if ($user->is_admin || $user->id == $this->item['user_id']) {
  2.  
  3. cmsTemplate::getInstance()->addCSS('templates/default/css/field-changestatus.css');

Если хотите возразить, сразу скажу, что мне такой способ подключения больше нравится. 

Добавим стили для поля и для кнопки в файле field-changestatus.css:

  1. .changestatus_btn{
  2. display: inline-block;
  3. vertical-align: top;
  4. color: #fff;
  5. font-size: 15px;
  6. line-height: 18px;
  7. padding: 7px 10px;
  8. border-radius: 3px;
  9. cursor: pointer;
  10. transition: all ease .15s;
  11. }
  12.  
  13. .changestatus_off{
  14. background: #d00000;
  15. }
  16.  
  17. .changestatus_off:hover{
  18. background: #e50000;
  19. }

Так вроде лучше. 

Изображение

Но это у нас простой div, а не кнопка. Давайте ее оживим. Допишем ей атрибут onclick:

  1. $btn_off = '<div class="changestatus_btn changestatus_off changestatus_off_'.$cl.'" onclick="statusOff('.$data.')">'.$off_text.'</div>';

При клике на эту кнопку будет выполняться функция javascript statusOff, ей мы передаем данные, которые содержатся в переменной $data — ее мы объявим ниже. Это имя типа контента, id записи и имя поля.

  1. public function parse($value){
  2.  
  3. $user = cmsUser::getInstance();
  4.  
  5. $cl = $this->item['ctype_name'].'_'.$this->item['id'].'_'.$this->name;
  6.  
  7. $data = '\''.$this->item['ctype_name'].'\', '.$this->item['id'].', \''.$this->name.'\''; // объявляем переменную $data
  8.  
  9. if ($value == 'actual') {
  10.  
  11. if ($user->is_admin || $user->id == $this->item['user_id']) {
  12.  
  13. cmsTemplate::getInstance()->addCSS('templates/default/css/field-changestatus.css');
  14.  
  15. $off_text = $this->options['btn_off'] ? $this->options['btn_off'] : 'Больше неактуально?';
  16.  
  17. $btn_off = '<div class="changestatus_btn changestatus_off changestatus_off_'.$cl.'" onclick="statusOff('.$data.')">'.$off_text.'</div>';
  18.  
  19. return $btn_off;
  20.  
  21. }
  22.  
  23. return '';
  24.  
  25. }
  26.  
  27. return '';
  28.  
  29. }

Но у нас нет такой функции. Давайте напишем. В папке templates/default/js создалим файл field-changestatus.js и подключим его по аналогии с подключением css-файла:

  1. if ($user->is_admin || $user->id == $this->item['user_id']) {
  2.  
  3. cmsTemplate::getInstance()->addCSS('templates/default/css/field-changestatus.css');
  4.  
  5. cmsTemplate::getInstance()->addJS('templates/default/js/field-changestatus.js'); // Подключаем js-файл


В самом файле напишем нашу функцию, в которой отправим запрос ajax к экшену changestatus_off контроллера content с нашими данными. У нас еще нет такого экшена, сейчас создадим. В папке system/controllers/content/actions создадим файл changestatus_off.php. В нем объявим класс actionContentChangestatusOff, наследуемый от системного класса cmsAction. Внутри класса добавим метод run(), в который прилетают наши данные из кнопки:

  1. <?php class actionContentChangestatusOff extends cmsAction {
  2.  
  3. public function run($ctype_name, $item_id, $field_name) {
  4.  
  5. }
  6.  
  7. }

Нам нужно вернуть массив с данными, которые мы сейчас получим. Поэтому сразу объявим переменную $result, которая будет массивом.

Может быть так, что у автора была открыта вкладка долгое время. За это время могло произойти многое. Например, админ сменил владельца. Или пользователь разлогинился в другой вкладке. Давайте сначала проверим, что пользователь имеет право менять статус записи. Если нет, то вернем ошибку, а если да — то будем продолжать. Думаю, стоит разделить ошибку на две части — если пользователь разлогинился и если не имеет прав.

  1. <?php class actionContentChangestatusOff extends cmsAction {
  2.  
  3. public function run($ctype_name, $item_id, $field_name) {
  4.  
  5. $result = [];
  6.  
  7. $user = cmsUser::getInstance(); // Получаем пользователя
  8.  
  9. $item = $this->model->getItemById('con_'.$ctype_name, $item_id); // Получаем запись
  10.  
  11. if (!$user->is_admin && $user->id != $item['user_id']) { // Если это не админ и не автор
  12.  
  13. $result['action'] = 'error';
  14.  
  15. $result['type'] = 'forbidden';
  16.  
  17. $result['text'] = 'Вам запрещено это делать!';
  18.  
  19. if (!$user->is_logged) { // Если разлогинился
  20.  
  21. $result['type'] = 'guest';
  22.  
  23. $result['text'] = 'Вы вышли из аккаунта!';
  24.  
  25. }
  26.  
  27. echo json_encode($result);
  28.  
  29. $this->halt();
  30.  
  31. }
  32.  
  33. $result['action'] = 'ok';
  34.  
  35. $result['text'] = '';
  36.  
  37. echo json_encode($result);
  38.  
  39. $this->halt();
  40.  
  41. }
  42.  
  43. }

Добавим в js-функции условие: если получили ошибку, то выводим текст ошибки:

  1. function statusOff(ctype_name, item_id, field_name) {
  2.  
  3. $.post('/content/changestatus_off/' + ctype_name + '/' + item_id + '/' + field_name).done(function(response) {
  4.  
  5. result = $.parseJSON(response);
  6.  
  7. if (result.action === 'error') {
  8.  
  9. $('.changestatus_off_' + ctype_name + '_' + item_id + '_' + field_name).removeAttr('onclick').addClass('error_btn').html(result.text);
  10.  
  11. }
  12.  
  13. });
  14.  
  15. }

Разберем строку

  1. $('.changestatus_off_' + ctype_name + '_' + item_id + '_' + field_name).removeAttr('onclick').addClass('error_btn').html(result.text);

В ней мы вот, что делаем с кнопкой:

  • удаляем атрибут onclick — removeAttr('onclick')
  • добавляем класс error_btn — addClass('error_btn')
  • заменяем текст кнопки на сообщение об ошибке.

Только что мы добавили для кнопки новый класс. Давайте добавим для этого класса немного стилей в файле css:

  1. .changestatus_btn.error_btn{
  2. cursor: text!important;
  3. background: transparent!important;
  4. color: #e50000!important;
  5. padding: 7px 0;
  6. }

Изображение

С ошибками разобрались. Теперь нужно что-то сделать, если пользователю можно продолжать. Давайте ему предложим выбрать причину. А если причина не указана или если статус меняет админ, то хотя бы предупреждение, что объявление будет скрыто. Опять работаем в файле system/controllers/content/actions/changestatus_off.php. Получим список причин из опций поля:

  1. <?php class actionContentChangestatusOff extends cmsAction {
  2.  
  3. public function run($ctype_name, $item_id, $field_name) {
  4.  
  5. $result = [];
  6.  
  7. $user = cmsUser::getInstance();
  8.  
  9. $item = $this->model->getItemById('con_'.$ctype_name, $item_id);
  10.  
  11. if (!$user->is_admin && $user->id != $item['user_id']) {
  12.  
  13. $result['action'] = 'error';
  14.  
  15. $result['text'] = 'Вам запрещено это делать!';
  16.  
  17. if (!$user->is_logged) {
  18.  
  19. $result['text'] = 'Вы вышли из аккаунта!';
  20.  
  21. }
  22.  
  23. echo json_encode($result);
  24.  
  25. $this->halt();
  26.  
  27. }
  28.  
  29. $result['action'] = 'ok';
  30.  
  31. $field = $this->model->getItemByField('con_'.$ctype_name.'_fields', 'name', $field_name); // Получаем поле
  32.  
  33. $field_options = $this->model->yamlToArray($field['options']); // Получаем опции поля и конвертируем их и формата yaml в обычный массив
  34.  
  35. $reasons = $field_options['reason'] ? preg_split('/\\r\\n?|\\n/', $field_options['reason']) : ''; // Получаем массив из поля с причинами
  36.  
  37. $reasons_list = [];
  38.  
  39. if ($reasons) {
  40.  
  41. foreach ($reasons as $key => $reason) {
  42.  
  43. $reasons_list[] = '<option value="'.$key.'">'.$reason.'</option>'; // Собираем новый массив, где значения становятся элементами выпадающего списка
  44.  
  45. }
  46.  
  47. }
  48.  
  49. $result['text'] = 'Материал будет помечен, как неактуальный. Вы уверены, что хотите продолжить?';
  50.  
  51. if ($reasons_list && !$user->is_admin) {
  52.  
  53. $list = '<select>'.implode($reasons_list).'</select>';
  54.  
  55. $result['text'] = 'Укажите, пожалуйста, причину, по которой материал больше неактуален.'.$list;
  56.  
  57. }
  58.  
  59. echo json_encode($result);
  60.  
  61. $this->halt();
  62.  
  63. }
  64.  
  65. }

Мы получили текст сообщения. Но его теперь надо где-то вывести. Напишем всплывающее окно. В этом же файле немного изменим наш код:

  1. $result_text = '<p class="changestatus_text">Материал будет помечен, как неактуальный. Вы уверены, что хотите продолжить?</p>';
  2.  
  3. if ($reasons_list && !$user->is_admin) {
  4.  
  5. $list = '<select>'.implode($reasons_list).'</select>';
  6.  
  7. $result_text = '<p class="changestatus_text">Укажите, пожалуйста, причину, по которой материал больше неактуален.</p>'.$list;
  8.  
  9. }
  10.  
  11. $ctype = $this->model->getItemByField('content_types', 'name', $ctype_name);
  12.  
  13. $ctype_labels = $this->model->yamlToArray($ctype['labels']);
  14.  
  15. $label = '<p class="changestatus_label">Изменение статуса '.$ctype_labels['two'].'</p> <h3>&laquo;'.$item['title'].'&raquo;</h3>';
  16.  
  17. $result['text'] = '<div class="changestatus_modal changestatus_modal_'.$ctype_name.'_'.$item_id.'_'.$field_name.'"><div class="changestatus_modal_body">'.$label.$result_text.'</div></div>';

И в js-файле вызовем это всплывающее окно:

  1. if (result.action === 'ok') {
  2.  
  3. $('body').append(result.text);
  4.  
  5. }

Добавим стилей:

  1. .changestatus_modal{
  2. width: 100%;
  3. height: 100%;
  4. position: fixed;
  5. z-index: 100;
  6. background: rgba(0,0,0,.7);
  7. animation: changestatus_show ease-out .3s 1;
  8. }
  9.  
  10. .changestatus_modal_body{
  11. width: 540px;
  12. max-width: calc(100% - 40px);
  13. padding:35px 30px;
  14. border-radius:3px;
  15. position: absolute;
  16. background: #fff;
  17. left: 50%;
  18. top: 50%;
  19. transform: translate(-50%, -50%);
  20. }
  21.  
  22. .changestatus_modal .changestatus_label{
  23. margin: 0;
  24. line-height: 115%;
  25. font-weight: bold;
  26. }
  27.  
  28. .changestatus_modal h3{
  29. margin-bottom: 20px;
  30. line-height: 115%;
  31. }
  32.  
  33. .changestatus_modal select{
  34. width: 100%;
  35. height: 36px;
  36. border: 1px solid #a1a1a1;
  37. border-radius: 3px;
  38. margin-bottom: 15px;
  39. }
  40.  
  41. @keyframes changestatus_show{
  42. from{
  43. opacity:0;
  44. }
  45. to{
  46. opacity:1;
  47. }
  48. }

Теперь, если всё нормально, автор получит такое модальное окошко, если в опциях указали причины:

Изображение

А если пользователь админ или автор, но причин для выбора нет, то такое:

Изображение

Чего-то не хватает, да? Правильно, кнопок. Во-первых, нужна кнопка отмены, чтобы закрыть это окно. Во-вторых, нужна кнопка для продолжения. Напишем их. Для этого немного изменим код в файле system/controllers/content/actions/changestatus_off.php:

  1. $btn_continue = '<div class="changestatus_btn changestatus_continue" onclick="statusOffContinue(\''.$ctype_name.'\', '.$item_id.', \''.$field_name.'\')">Продолжить</div>';
  2.  
  3. $btn_cancel = '<div class="changestatus_btn changestatus_cancel" onclick="statusCancel(\''.$ctype_name.'\', '.$item_id.', \''.$field_name.'\')">Отменить</div>';
  4.  
  5. $buttons = '<div class="changestatus_btns">'.$btn_continue.$btn_cancel.'</div>';
  6.  
  7. $result['text'] = '<div class="changestatus_modal changestatus_modal_'.$ctype_name.'_'.$item_id.'_'.$field_name.'"><div class="changestatus_modal_body">'.$label.$result_text.$buttons.'</div></div>';

И добавим стилей для кнопок:

  1. .changestatus_modal .changestatus_btns{
  2. margin-top: 10px;
  3. display: inline-block;
  4. vertical-align: top;
  5. }
  6.  
  7. .changestatus_continue{
  8. background: #d00000;
  9. margin-right:10px;
  10. }
  11.  
  12. .changestatus_continue:hover{
  13. background: #e50000;
  14. }
  15.  
  16. .changestatus_cancel{
  17. background: #b4a8ac;
  18. }
  19.  
  20. .changestatus_cancel:hover{
  21. background: #a89da0;
  22. }

Получается вот так:

Изображение

Оживим кнопки. Начнем с кнопки отмены — она проще. Пишем в js-файл новую функцию:

  1. function statusCancel(ctype_name, item_id, field_name) {
  2.  
  3. $('.changestatus_modal_' + ctype_name + '_' + item_id + '_' + field_name).remove();
  4.  
  5. return;
  6.  
  7. }

Здесь всё просто — при нажатии на кнопку мы удаляем модальное окно. А вот следующая функция будет посложнее. В ней мы отправим аякс-запрос к новому экшену. Но передадим не только те данные, которые мы передаем по кругу с самого начала, а еще и выбранный пункт из выпадающего списка. Итак, пишем в js-файле нашу функцию:

  1. function statusOffContinue(ctype_name, item_id, field_name) {
  2.  
  3. var reasons = $('.changestatus_modal_' + ctype_name + '_' + item_id + '_' + field_name + ' select');
  4.  
  5. if (reasons.length) {
  6.  
  7. var reason = reasons.val();
  8.  
  9. } else {
  10.  
  11. var reason = 'x';
  12.  
  13. }
  14.  
  15. $.post('/content/changestatus_off_confirm/' + ctype_name + '/' + item_id + '/' + field_name + '/' + reason).done(function(response) {
  16.  
  17. result = $.parseJSON(response);
  18.  
  19. });
  20.  
  21. }

Здесь мы проверили, существует ли наш выпадающий список. Если да, то reason — это ключ выбранной причины, а если нет — передаем «х». 

Теперь нужно в папке system/controllers/content/actions создать файл changestatus_off_continue.php. В нем точно так же, как и в предыдущем экшене, начинаем с этого:

  1. <?php class actionContentChangestatusOff extends cmsAction {
  2.  
  3. public function run($ctype_name, $item_id, $field_name, $reason_id) {
  4.  
  5. }
  6.  
  7. }

Думаю, стоит опять проверить, может ли пользователь менять статус. Ведь пока была открыта форма, он мог, например, разлогиниться в соседней вкладке. Поэтому сначала просто скопируем часть кода из файла system/controllers/content/actions/changestatus_off.php:

  1. <?php class actionContentChangestatusOff extends cmsAction {
  2.  
  3. public function run($ctype_name, $item_id, $field_name, $reason_id) {
  4.  
  5. $result = [];
  6.  
  7. $user = cmsUser::getInstance();
  8.  
  9. $item = $this->model->getItemById('con_'.$ctype_name, $item_id);
  10.  
  11. if (!$user->is_admin && $user->id != $item['user_id']) {
  12.  
  13. $result['action'] = 'error';
  14.  
  15. $result['text'] = 'Вам запрещено это делать!';
  16.  
  17. if (!$user->is_logged) {
  18.  
  19. $result['text'] = 'Вы вышли из аккаунта!';
  20.  
  21. }
  22.  
  23. echo json_encode($result);
  24.  
  25. $this->halt();
  26.  
  27. }
  28.  
  29. }
  30.  
  31. }

И в javascript-функции напишем условие для вывода ошибки:

  1. if (result.action === 'error') {
  2.  
  3. statusCancel(ctype_name, item_id, field_name);
  4.  
  5. $('.changestatus_off_' + ctype_name + '_' + item_id + '_' + field_name).removeAttr('onclick').addClass('error_btn').html(result.text);
  6.  
  7. }

Обратите внимание, что перед тем, как вывести сообщение об ошибке, мы вызвали функцию statusCancel(), которая удаляет со страницы модальное окно с формой.

Продолжаем писать внутри метода run() в файле changestatus_off_continue.php. Сначала напишем, что у нас нет ошибки, а также текст, который выведем после завершения:

  1. $result['action'] = 'ok';
  2.  
  3. $result['text'] = 'Неактуально';

Дальше разберемся с причинами:

  1. $reason = 'Причина не указана'; // Объявим переменную $reason, которая по-умолчанию будет принимать значение "Причина не указана"
  2.  
  3. if ($reason_id != 'x') { // Если у нас в форме был выпадающий список и значение выбрано
  4.  
  5. $field = $this->model->getItemByField('con_'.$ctype_name.'_fields', 'name', $field_name);
  6.  
  7. $field_options = $this->model->yamlToArray($field['options']);
  8.  
  9. $reasons = $field_options['reason'] ? preg_split('/\\r\\n?|\\n/', $field_options['reason']) : '';
  10.  
  11. if ($reasons) {
  12.  
  13. $reason = $reasons[$reason_id]; // $reason принимает текст выбранного элемента в списке
  14.  
  15. }
  16.  
  17. }
  18.  
  19. if ($user->is_admin) { // А вот такая будет причина, если статус сменил админ
  20.  
  21. $reason = 'Статус изменен решением администрации сайта';
  22.  
  23. }

Ну вот и всё, давайте теперь обновим наше поле в БД:

  1. $this->model->update('con_'.$ctype_name, $item_id, [$field_name => $reason]);

И нужно остановить всё и выйти из скрипта:

  1. echo json_encode($result);
  2.  
  3. $this->halt();

В этом файле всё. Теперь в js напишем, что нужно сделать:

  1. if (result.action === 'ok') {
  2.  
  3. statusCancel(ctype_name, item_id, field_name);
  4.  
  5. $('.changestatus_off_' + ctype_name + '_' + item_id + '_' + field_name).removeAttr('onclick').addClass('error_btn').html(result.text);
  6.  
  7. }

Здесь мы удалили форму и в кнопку написали текст «Неактуально». Если присмотреться, то мы увидим, что с ошибкой и без нее мы выполняем одинаковые действия. Поэтому мы уберем все условия и приведем код функции к такому виду:

  1. function statusOffContinue(ctype_name, item_id, field_name) {
  2.  
  3. var reasons = $('.changestatus_modal_' + ctype_name + '_' + item_id + '_' + field_name + ' select');
  4.  
  5. if (reasons.length) {
  6.  
  7. var reason = reasons.val();
  8.  
  9. } else {
  10.  
  11. var reason = 'x';
  12.  
  13. }
  14.  
  15. $.post('/content/changestatus_off_continue/' + ctype_name + '/' + item_id + '/' + field_name + '/' + reason).done(function(response) {
  16.  
  17. result = $.parseJSON(response);
  18.  
  19. statusCancel(ctype_name, item_id, field_name);
  20.  
  21. $('.changestatus_off_' + ctype_name + '_' + item_id + '_' + field_name).removeAttr('onclick').addClass('error_btn').html(result.text);
  22.  
  23. });
  24.  
  25. }

Теперь вернемся к файлу поля system/fields/shangestatus.php. Сейчас нас интересует метод parse():

  1. public function parse($value){
  2.  
  3. $user = cmsUser::getInstance();
  4.  
  5. $cl = $this->item['ctype_name'].'_'.$this->item['id'].'_'.$this->name;
  6.  
  7. $data = '\''.$this->item['ctype_name'].'\', '.$this->item['id'].', \''.$this->name.'\'';
  8.  
  9. if ($value == 'actual') {
  10.  
  11. if ($user->is_admin || $user->id == $this->item['user_id']) {
  12.  
  13. cmsTemplate::getInstance()->addCSS('templates/default/css/field-changestatus.css');
  14.  
  15. cmsTemplate::getInstance()->addJS('templates/default/js/field-changestatus.js');
  16.  
  17. $off_text = $this->options['btn_off'] ? $this->options['btn_off'] : 'Больше неактуально?';
  18.  
  19. $btn_off = '<div class="changestatus_btn changestatus_off changestatus_off_'.$cl.'" onclick="statusOff('.$data.')">'.$off_text.'</div>';
  20.  
  21. return $btn_off;
  22.  
  23. }
  24.  
  25. return '';
  26.  
  27. }
  28.  
  29. return '';
  30.  
  31. }

Смотрите, мы написали условие, что если значение поля равно «actual», то показываем кнопку, если пользователь админ или автор. Но мы ничего не выводим, если значение поля другое. Давайте это исправим. У нас будет выводиться надпись «Неактуально» или текст из опций поля. А рядом каким-то образом выведем причину. Начнем с надписи. В конце метода последняя строка содержит return ''; Заменим ее на это:

  1. return '<div class="changestatus_off_text">'.($this->options['not_actual'] ? $this->options['not_actual'] : 'Неактуально').'</div>';

И добавим стилей:

  1. .changestatus_off_text{
  2. display: inline-block;
  3. vertical-align: top;
  4. color: #fff;
  5. font-size: 15px;
  6. line-height: 18px;
  7. padding: 7px 10px;
  8. background: #a89da0;
  9. font-weight: bold;
  10. border-radius:3px;
  11. cursor: help;
  12. }

Но раньше мы подключали css и js не для всех и не всегда. Эти файлы подключены только если значение поля «actual» и если пользователь автор или админ. Давайте переместим подключение файлов выше, но добавим условие, чтобы они не были подключены тогда, когда это не нужно. После этого метод parse будет выглядеть так:

  1. public function parse($value){
  2.  
  3. $user = cmsUser::getInstance();
  4.  
  5. $cl = $this->item['ctype_name'].'_'.$this->item['id'].'_'.$this->name;
  6.  
  7. $data = '\''.$this->item['ctype_name'].'\', '.$this->item['id'].', \''.$this->name.'\'';
  8.  
  9. // Подключаем css и js файлы только если пользователь автор или админ или если значение не равно "actual"
  10.  
  11. if ($value != 'actual' || $user->is_admin || $user->id == $this->item['user_id']) {
  12.  
  13. cmsTemplate::getInstance()->addCSS('templates/default/css/field-changestatus.css');
  14.  
  15. cmsTemplate::getInstance()->addJS('templates/default/js/field-changestatus.js');
  16.  
  17. }
  18.  
  19. if ($value == 'actual') {
  20.  
  21. if ($user->is_admin || $user->id == $this->item['user_id']) {
  22.  
  23. $off_text = $this->options['btn_off'] ? $this->options['btn_off'] : 'Больше неактуально?';
  24.  
  25. $btn_off = '<div class="changestatus_btn changestatus_off changestatus_off_'.$cl.'" onclick="statusOff('.$data.')">'.$off_text.'</div>';
  26.  
  27. return $btn_off;
  28.  
  29. }
  30.  
  31. return '';
  32.  
  33. }
  34.  
  35. return '<div class="changestatus_off_text">'.($this->options['not_actual'] ? $this->options['not_actual'] : 'Не актуально').'</div>';
  36.  
  37. }

Получилось так:

Изображение

Теперь сюда же добавим причину:

  1. return '<div class="changestatus_off_text" title="'.$value.'">'.($this->options['not_actual'] ? $this->options['not_actual'] : 'Неактуально').'</div>';

Здесь мы добавили описание причины в title блока. Немного приукрасим. Добавим в js-файл такой скрипт:

  1. function tooltip(target_items, name) {
  2.  
  3. $(target_items).each(function(i) {
  4.  
  5. $('body').append('<div class="' + name + '" id="' + name + i + '">' + $(this).attr('title') + '</div>');
  6.  
  7. var tooltip = $('#' + name + i);
  8.  
  9. $(this).removeAttr('title').mouseover(function() {
  10.  
  11. tooltip.css({opacity:0.8, display:"none"}).fadeIn(400);
  12.  
  13. }).mousemove(function(kmouse) {
  14.  
  15. tooltip.css({left:kmouse.pageX+15, top:kmouse.pageY+15});
  16.  
  17. }).mouseout(function() {
  18.  
  19. tooltip.fadeOut(250);
  20.  
  21. });
  22. });
  23. }
  24.  
  25. $(document).ready(function(){
  26.  
  27. tooltip('.changestatus_off_text', 'tooltip');
  28.  
  29. });

Скажу честно, я его нашел в интернете лет 10 назад. Объяснять, как он работает, не буду. Скажу только, что мы с помощью этого скрипта преобразуем title блока во всплывающую подсказу (tooltip). Добавим стилей:

  1. .tooltip{
  2. position: absolute;
  3. z-index: 999;
  4. left: -9999px;
  5. background: rgba(0,0,0,.9);
  6. color: #f0f0f0;
  7. padding: 5px 7px;
  8. border-radius: 3px;
  9. width: 200px;
  10. font-size:13px;
  11. }

Теперь это выглядит так:

Изображение
Изображение

С этим разобрались. 

Но если мы добавили возможность делать запись неактуальной, то как же ее вернуть? А что, если она автоматически будет становиться актуальной после редактирования? Так и запишем. В файле поля system/fields/shangestatus.php напишем новый метод store(), который отвечает за то, что сохраняется в поле при добавлении или редактировании записи. Внутри метода вернем значение 'actual':

  1. public function store($value, $is_submitted, $old_value=null) {
  2.  
  3. return 'actual';
  4.  
  5. }

Теперь разберемся с фильтром. Так как поле не выводится при добавлении или редактировании записи, то ему можно дать заголовок «Только актуальные».

Изображение

И напишем два метода для фильтра.

  1. public function applyFilter($model, $value) {
  2.  
  3. return $model->filterEqual($this->name, 'actual');
  4.  
  5. }

В этом методе мы отфильтровали все записи по значению «actual». Добавим еще один метод — чтобы в форме фильтра показать переключатель:

  1. public function getFilterInput($value = false) {
  2.  
  3. cmsTemplate::getInstance()->addBottom('<script>$(document).ready(function(){$("#f_'.$this->id.' .input-checkbox").on("change", function(){$("#f_'.$this->id.'").toggle();});var state_'.$this->id.' = $("#f_'.$this->id.' .input-checkbox").prop("checked");if(state_'.$this->id.'){$("#f_'.$this->id.'").show();}});</script>');
  4.  
  5. return '<div class="custom-control custom-switch">'.html_checkbox($this->name, $value, 'actual', ['id'=>$this->id,'class'=>'custom-control-input']).'<label class="custom-control-label" for="'.$this->id.'">'.$this->title.'</label></div>';
  6.  
  7. }

Ну вот, кажется, и всё. Но наверняка вы захотите использовать поле в фильтрах наборов. Например, показать только актуальные или только неактуальные. Для этого в наборе добавьте фильтр по полю. Если нужно показать только актуальные, то "= actual":

Изображение

А если неактуальные, то "не содержит actual".


Изображение

Вот и всё. Крутое поле готово. Осталось упаковать его в архив. Для этого создаем папку changestatus, в ней будет вся наша кухня. Внутри нее создаем дерево папок и файлов:

package
> system
>> fields
>>> changestatus.php
>> system
>>> controllers
>>>> content
>>>>> actions
>>>>>> changestatus_off.php
>>>>>> changestatus_off_continue.php
> templates
>> default
>>> assets
>>>> fields
>>>>> changestatus.tpl.php
>>> css
>>>> field-changestatus.css
>>> js
>>>> field-changestatus.js

Содержимое файлов вы знаете, где брать.

Рядом с папкой package создаем файл manifest.ru.ini с таким содрежимым (или другим):

  1. [info]
  2. title = "Поле &laquo;Смена статуса&raquo;"
  3.  
  4. [version]
  5. major = "1"
  6. minor = "0"
  7. build = "0"
  8. date = "20220715"
  9.  
  10. [depends]
  11. core = "2.12.2"
  12.  
  13. [author]
  14. name = "Нифигассе о-го-гошеньки"
  15. url = "https://nifigasse.ru"
  16.  
  17. [description]
  18. text[] = "Поле позволяет сделать запись не акутальной без перезагрузки страницы"
  19. text[] = "Распространяется БЕСПЛАТНО и без каких-либо ограничений."
  20. text[] = "Код полностью открыт. Автор не несет никакой ответственности в связи с использованием вами поля на своих сайтах. Поддержка не оказывается."

Теперь в папке changestatus должно быть два элемента — папка package с файлами и файл манифеста. Выделяем эти два элемента и упаковываем в zip-архив.

Вот такие вот дела. Вроде закончил.

Скачать готовое поле можно в каталоге дополнений. 

Ну и домашнее задание)) Можете добавить опции для замены всех текстов, прописанных прямо в файлах. Мне можете не отчитываться — проверять не буду.

А в следующий раз, когда мне будет скучно и я решу сделать нечто подобное, мы с вами напишем небольшой компонент, в котором установим срок публикации записи, и вместо удаления или скрытия, как это сделано в типах контента, будем помечать, что запись перестала быть актуальной. Только я не знаю, когда это будет))

=====================================================

Приняв во внимание рекомендации Fuze, немного изменим наше поле.

Во-первых, в самом начале метода parse() в файле system/fields/changestatus.php добавим такую такое условие:

  1. if (!isset($this->item) || !$this->item['id'] || !$this->item['ctype_name'] || !$this->item['user_id']) {
  2.  
  3. return '';
  4.  
  5. }


Здесь мы проверили, существует ли массив $this->item и есть ли в нем значения, которые используются данные. Если чего-то нет, то не выводим поле. Я специально сделал не так, как предложил Fuze, чтобы послушать критику по этому поводу. Как говорится, век живи — век учись))

Дальше нам нужно поработать над безопасностью. Сейчас в кнопке мы передаем имена типа контента и поля. А сделаем так: будем передавать id типа контента и поля, в экшенах будем проверять, что получили число. Ну и еще пару проверок в самом начале экшенов. 

Делаем так. В файле system/fields/changestatus.php содержится код кнопки, в котором передается значение переменной $data в javascript-функцию statusOff(). Вот этот код:

  1. if (!isset($this->item) || !$this->item['id'] || !$this->item['ctype_name'] || !$this->item['user_id']) {
  2.  
  3. return '';
  4.  
  5. }


Переменная $data объявлена чуть выше:

  1. $data = '\''.$this->item['ctype_name'].'\', '.$this->item['id'].', \''.$this->field_id.'\'';


Изменим значение переменной на такое:

  1. $data = $this->ctype_id.', '.$this->item['id'].', '.$this->field_id;


Точно так же сделаем и с переменной $cl:

  1. $cl = $this->ctype_id.'_'.$this->item['id'].'_'.$this->field_id;


Но теперь у нас в коде вообще нигде нет $this->item['ctype_name']. Значит можно не проверять, существует ли это значение. Поэтому, в том условии, которое мы только что прописали в начале метода parse(), можно убрать проверку $this->item['ctype_name']:

  1. if (!isset($this->item) || !$this->item['id'] || !$this->item['user_id']) {
  2.  
  3. return '';
  4.  
  5. }


Теперь открываем файл templates/default/js/field-changestatus.js. Туда пришли наши данные. Можно, в принципе, ничего не менять, всё и так будет работать. Но лучше изменим имена. А то через полгода вернемся к доработке этого поля и будем долго вникать, что за ctype_name, если приходит на самом деле число. В файле делаем замену всех вхождений ctype_name на ctype_id и field_name на field_id. Сохраняем и закрываем файл — дальше будем работать в экшенах.

Начнем с system/controllers/content/actions/changestatus_off.php

Сразу изменяем названия переменных, которые приходят в этот экшен:

  1. public function run($ctype_id, $item_id, $field_id) {


Теперь проверяем, что получены числа, а не что-то еще:

  1. if ((int)$ctype_id <= 0 || (int)$item_id <= 0 || (int)$field_id <= 0) {
  2.  
  3. $result['action'] = 'error';
  4.  
  5. $result['text'] = 'Ай-я-яй, как не стыдно?';
  6.  
  7. echo json_encode($result);
  8.  
  9. $this->halt();
  10.  
  11. }


Теперь если взломщик подсунет что-то другое, кроме числа, то мы его поругаем. Теперь давайте проверим, что полученные данные настоящие. Посмотрим, есть ли у нас тип контента с полученным id:

  1. $ctype = $this->model->getContentType($ctype_id);
  2.  
  3. if (!$ctype) {
  4.  
  5. $result['action'] = 'error';
  6.  
  7. $result['text'] = 'Плохо, очень плохо!';
  8.  
  9. echo json_encode($result);
  10.  
  11. $this->halt();
  12.  
  13. }


Если есть, то проверим, есть ли у нас поле, с полученным id. И если есть, то наш ли у него тип:

  1. $ctype_name = $ctype['name'];
  2.  
  3. $field = $this->model->getItemById('con_'.$ctype_name.'_fields', $field_id);
  4.  
  5. if (!$field || $field['type'] != 'changestatus') {
  6.  
  7. $result['action'] = 'error';
  8.  
  9. $result['text'] = 'А ну, марш в угол!';
  10.  
  11. echo json_encode($result);
  12.  
  13. $this->halt();
  14.  
  15. }


И объявим переменную $field_name, так как она у нас используется позже:

  1. $field_name = $field['name'];

Дальше у нас идет такой код:

  1. $result = [];
  2.  
  3. $user = cmsUser::getInstance();
  4.  
  5. $item = $this->model->getItemById('con_'.$ctype_name, $item_id);


Переместим $item вверх:

  1. $item = $this->model->getItemById('con_'.$ctype_name, $item_id);
  2.  
  3. $result = [];
  4.  
  5. $user = cmsUser::getInstance();


И проверим, существует ли запись:

  1. if (!$item) {
  2.  
  3. $result['action'] = 'error';
  4.  
  5. $result['text'] = 'Ну всё, приплыли...';
  6.  
  7. echo json_encode($result);
  8.  
  9. $this->halt();
  10.  
  11. }

Чуть не забыл. Нужно в внизу этого файла немного поменять кнопки и классы:

  1. $btn_continue = '<div class="changestatus_btn changestatus_continue" onclick="statusOffContinue(\''.$ctype_id.'\', '.$item_id.', \''.$field_id.'\')">Продолжить</div>';
  1. $btn_cancel = '<div class="changestatus_btn changestatus_cancel" onclick="statusCancel(\''.$ctype_id.'\', '.$item_id.', \''.$field_id.'\')">Отменить</div>';
  1. $result['text'] = '<div class="changestatus_modal changestatus_modal_'.$ctype_id.'_'.$item_id.'_'.$field_id.'"><div class="changestatus_modal_body">'.$label.$result_text.$buttons.'</div></div>';


В этом файле всё, перейдем к system/controllers/content/actions/changestatus_off_continue.php. Начало там такое же, поэтому сделаем всё то же самое:

  1. <?php class actionContentChangestatusOffContinue extends cmsAction {
  2.  
  3. public function run($ctype_id, $item_id, $field_id, $reason_id) {
  4.  
  5. if ((int)$ctype_id <= 0 || (int)$item_id <= 0 || (int)$field_id <= 0) {
  6.  
  7. $result['action'] = 'error';
  8.  
  9. $result['text'] = 'Ай-я-яй, как не стыдно?';
  10.  
  11. echo json_encode($result);
  12.  
  13. $this->halt();
  14.  
  15. }
  16.  
  17. $ctype = $this->model->getContentType($ctype_id);
  18.  
  19. if (!$ctype) {
  20.  
  21. $result['action'] = 'error';
  22.  
  23. $result['text'] = 'Плохо, очень плохо!';
  24.  
  25. echo json_encode($result);
  26.  
  27. $this->halt();
  28.  
  29. }
  30.  
  31. $ctype_name = $ctype['name'];
  32.  
  33. $field = $this->model->getItemById('con_'.$ctype_name.'_fields', $field_id);
  34.  
  35. if (!$field || $field['type'] != 'changestatus') {
  36.  
  37. $result['action'] = 'error';
  38.  
  39. $result['text'] = 'А ну, марш в угол!';
  40.  
  41. echo json_encode($result);
  42.  
  43. $this->halt();
  44.  
  45. }
  46.  
  47. $field_name = $field['name'];

Но в этот экшен приходит еще значение выпадающего списка с причиной. Проверим и его. Находим этот код:

  1. $reason = 'Причина не указана';
  2.  
  3. if ($reason_id != 'x') {
  4.  
  5. $field = $this->model->getItemByField('con_'.$ctype_name.'_fields', 'name', $field_name);
  6.  
  7. $field_options = $this->model->yamlToArray($field['options']);
  8.  
  9. $reasons = $field_options['reason'] ? preg_split('/\\r\\n?|\\n/', $field_options['reason']) : '';
  10.  
  11. if ($reasons) {
  12.  
  13. $reason = $reasons[$reason_id];
  14.  
  15. }
  16.  
  17. }


И немного его исправляем в этом месте:

  1. if ($reasons) {
  2.  
  3. $reason = $reasons[$reason_id];
  4.  
  5. }


Проверим, существует ли значение массива с полученным id:

  1. if ($reasons && isset($reasons[$reason_id])) {
  2.  
  3. $reason = $reasons[$reason_id];
  4.  
  5. }


Вот и всё. Опасность, кажется, миновала))

+2
Tim T Tim T 1 месяц назад #

Спасибо за подробное объяснение/старания.
Читать подробный, с объяснениями блог — одно удовольствие!

0
fincheck fincheck 1 месяц назад #

Ого, с картинками, кодом и пояснениями! От души, это я скоро и кодить научусь?! 

0
Димон Димон 1 месяц назад #

Соглашусь читать блоги с детальным разбором всегда очень увлекательнл

0
Happy Happy 1 месяц назад #

Спасибо большое , хорошее поле и хороший подход, не останавливайтесь ) 

+1
Loadырь Loadырь 1 месяц назад #

👍 Наконец-то, хоть кто-то объяснил и показал, как это надо делать. Всё оказывается читаемо, легко и просто. Вообще не понимаю, за что тут люди дерут по 500 р. и выше 😂

0
Fuze Fuze 1 месяц назад #

Безусловный респект за такую большую работу. А так как пост всё же больше познавательный, позволю себе ремарки.

if ($user->is_admin || $user->id == $this->item['user_id']) {

$this->item может не быть. В методе parse стоит проверять на непустой массив. В самом начале можно добавить

  1. if (empty($this->item['id'])) {
  2. return '';
  3. }

В папке system/controllers/content/actions создадим файл changestatus_off.php.

Лучше для этого создать директорию со своим контроллером и там уже создавать экшены. Контроллер не обязательно добавлять в базу данных.

Давайте сначала проверим, что пользователь имеет право менять статус записи. Если нет, то вернем ошибку, а если да — то будем продолжать.

Это необходимо делать в самом начале экшена.

$item = $this->model->getItemById('con_'.$ctype_name, $item_id);

  1. Всегда нужно проверять входящие переменные. Передаём в $ctype_name произвольное имя — получаем SQL уязвимость. Это же касается и $field_name.
  2. Всегда нужно убеждаться, что запрашиваемая запись ($item и $field в данном случае) получена. Передаём в $item_id произвольное число — ниже по коду получаем кучу нотисов как минимум.

По структуре кода — лучше делать вот так. Я бы в экшене сначала проверил авторизацию, все входящие переменные, получил $ctype по $ctype_name — проверил что тип контента есть, потом получил запись — проверил что она есть, потом поле и так далее. Самое главное — все входящие переменные — проверять, получаемые записи — проверять на наличие, прежде чем с ними что-то делать дальше.

В методе getFilterInput лучше всё делать в шаблоне.

Вроде бы всё)

Ну и я всё же должен предупредить, что пользоваться данным полем в продакшене на данный момент небезопасно. Но как пример подхода в разработке полей отличнейший док.

p.s. проверьте ваши другие разработки по ремаркам выше.

0
Нифигаccе о-го-гошеньки Нифигаccе о-го-гошеньки 1 месяц назад #

Спасибо за отклик!)) Вас понял. Пока жалоб не было, но всё-таки лучше всё перепроверить. Вот прямо сейчас и займусь))

Хотя некоторые моменты, конечно, не совсем понятны)) Например, в каком случае у записи типа контента может не быть $this->item. И если его нет, то нет и $this->item['id'], чтобы проверить, не пустой ли он.

Это необходимо делать в самом начале экшена… Передаём в $ctype_name произвольное имя — получаем SQL уязвимость.

Сначала, как мы передадим в ctype_name произвольное имя? Ну да, можно подсунуть. Но дальше мы проверим, можно ли юзеру выполнять действие. Если это другой тип контента или другая запись, не принадлежащая юзеру, то он просто получит ошибку. А если вообще левое имя, то 503 ошибку, что таблица не найдена. Ну так это его проблема. Что «хакер» может сделать с этой ошибкой?

И да, в чем всё-таки опасность использования поля в текущем виде?

Я не спорю, просто интересно, почему так)) Чтобы что-то делать, лучше понимать, что делаешь. Тот человек, который меня всему научил, уже давно не на связи.

+2
Fuze Fuze 1 месяц назад #

в каком случае у записи типа контента может не быть $this->item

Навскидку не скажу, но такое может быть, с вами же как-то давно встречали такое.

И если его нет, то нет и $this->item['id'], чтобы проверить, не пустой ли он.

Проверка empty не даёт нотис, поэтому быстрее и удобнее проверить сразу наличие ключа id.

Но дальше мы проверим, можно ли юзеру выполнять действие

У вас получение $item идёт до проверок.

Если это другой тип контента или другая запись, не принадлежащая юзеру, то он просто получит ошибку.

Тип контента, запись, поле — всего этого по переданным параметрам может просто не быть в базе. В вашем коде, как я уже писал, пойдут нотисы, если что-то пойдёт не так. Всё нужно проверять, когда работаем с входными данными.

А если вообще левое имя, то 503 ошибку, что таблица не найдена. Ну так это его проблема. Что «хакер» может сделать с этой ошибкой?

Зачем вообще давать возможность перебирать таблицы на вашем сервере? Мы можем указать существующую таблицу, но не таблицу записи и как минимум в ответе не будет нужных ячеек. Мы можем передать не просто имя типа контента, а например

  1. posts` UNION SELECT ...

Вариантов много. И куда может привести SQL инъекция зависит от сообразительности атакующего.

И да, в чем всё-таки опасность использования поля в текущем виде?

Так я же написал все ошибки) Если бы это был просто грязный код — это одно, но по моему мнению указанные недочёты критичны.

Я не спорю, просто интересно, почему так)) Чтобы что-то делать, лучше понимать, что делаешь.

Если интересно, я могу на досуге переделать это поле, снабдив комментариями.

0
Нифигаccе о-го-гошеньки Нифигаccе о-го-гошеньки 1 месяц назад #

Да, точно, было что-то такое с несуществующим итемом. Только не могу вспомнить, что именно. Где-то на гитхабе вроде я писал, надо поискать.

Суть вроде понял. Но был бы не против почитать Ваши комментарии в переделанном поле))

0
Loadырь Loadырь 30 дней назад #

Надо переделать и не просто с комментариями, а с подробным объяснением почему именно так в данном случае, а не иначе. Это будет «весомый пендаль» в сторону саморазвития сторонних разработчиков.

0
KoRn KoRn 1 месяц назад #

Плюс в карму)

0
Нифигаccе о-го-гошеньки Нифигаccе о-го-гошеньки 30 дней назад #

Приняв во внимание рекомендации Fuze, немного изменим наше поле.

Во-первых, в самом начале метода parse() в файле system/fields/changestatus.php добавим такую такое условие:

  1. if (!isset($this->item) || !$this->item['id'] || !$this->item['ctype_name'] || !$this->item['user_id']) {
  2.  
  3. return '';
  4.  
  5. }

Здесь мы проверили, существует ли массив $this->item и есть ли в нем значения, которые используются данные. Если чего-то нет, то не выводим поле. Я специально сделал не так, как предложил Fuze, чтобы послушать критику по этому поводу. Как говорится, век живи — век учись))

Дальше нам нужно поработать над безопасностью. Сейчас в кнопке мы передаем имена типа контента и поля. А сделаем так: будем передавать id типа контента и поля, в экшенах будем проверять, что получили число. Ну и еще пару проверок в самом начале экшенов. 

Делаем так. В файле system/fields/changestatus.php содержится код кнопки, в котором передается значение переменной $data в javascript-функцию statusOff(). Вот этот код:

  1. if (!isset($this->item) || !$this->item['id'] || !$this->item['ctype_name'] || !$this->item['user_id']) {
  2.  
  3. return '';
  4.  
  5. }

Переменная $data объявлена чуть выше:

  1. $data = '\''.$this->item['ctype_name'].'\', '.$this->item['id'].', \''.$this->field_id.'\'';

Изменим значение переменной на такое:

  1. $data = $this->ctype_id.', '.$this->item['id'].', '.$this->field_id;

Точно так же сделаем и с переменной $cl:

  1. $cl = $this->ctype_id.'_'.$this->item['id'].'_'.$this->field_id;

Но теперь у нас в коде вообще нигде нет $this->item['ctype_name']. Значит можно не проверять, существует ли это значение. Поэтому, в том условии, которое мы только что прописали в начале метода parse(), можно убрать проверку $this->item['ctype_name']:

  1. if (!isset($this->item) || !$this->item['id'] || !$this->item['user_id']) {
  2.  
  3. return '';
  4.  
  5. }

Теперь открываем файл templates/default/js/field-changestatus.js. Туда пришли наши данные. Можно, в принципе, ничего не менять, всё и так будет работать. Но лучше изменим имена. А то через полгода вернемся к доработке этого поля и будем долго вникать, что за ctype_name, если приходит на самом деле число. В файле делаем замену всех вхождений ctype_name на ctype_id и field_name на field_id. Сохраняем и закрываем файл — дальше будем работать в экшенах.

Начнем с system/controllers/content/actions/changestatus_off.php

Сразу изменяем названия переменных, которые приходят в этот экшен:

  1. public function run($ctype_id, $item_id, $field_id) {

Теперь проверяем, что получены числа, а не что-то еще:

  1. if ((int)$ctype_id <= 0 || (int)$item_id <= 0 || (int)$field_id <= 0) {
  2.  
  3. $result['action'] = 'error';
  4.  
  5. $result['text'] = 'Ай-я-яй, как не стыдно?';
  6.  
  7. echo json_encode($result);
  8.  
  9. $this->halt();
  10.  
  11. }

Теперь если взломщик подсунет что-то другое, кроме числа, то мы его поругаем. Теперь давайте проверим, что полученные данные настоящие. Посмотрим, есть ли у нас тип контента с полученным id:

  1. $ctype = $this->model->getContentType($ctype_id);
  2.  
  3. if (!$ctype) {
  4.  
  5. $result['action'] = 'error';
  6.  
  7. $result['text'] = 'Плохо, очень плохо!';
  8.  
  9. echo json_encode($result);
  10.  
  11. $this->halt();
  12.  
  13. }

Если есть, то проверим, есть ли у нас поле, с полученным id. И если есть, то наш ли у него тип:

  1. $ctype_name = $ctype['name'];
  2.  
  3. $field = $this->model->getItemById('con_'.$ctype_name.'_fields', $field_id);
  4.  
  5. if (!$field || $field['type'] != 'changestatus') {
  6.  
  7. $result['action'] = 'error';
  8.  
  9. $result['text'] = 'А ну, марш в угол!';
  10.  
  11. echo json_encode($result);
  12.  
  13. $this->halt();
  14.  
  15. }

И объявим переменную $field_name, так как она у нас используется позже:

  1. $field_name = $field['name'];

Дальше у нас идет такой код:

  1. $result = [];
  2.  
  3. $user = cmsUser::getInstance();
  4.  
  5. $item = $this->model->getItemById('con_'.$ctype_name, $item_id);

Переместим $item вверх:

  1. $item = $this->model->getItemById('con_'.$ctype_name, $item_id);
  2.  
  3. $result = [];
  4.  
  5. $user = cmsUser::getInstance();

И проверим, существует ли запись:

  1. if (!$item) {
  2.  
  3. $result['action'] = 'error';
  4.  
  5. $result['text'] = 'Ну всё, приплыли...';
  6.  
  7. echo json_encode($result);
  8.  
  9. $this->halt();
  10.  
  11. }

Чуть не забыл. Нужно в внизу этого файла немного поменять кнопки и классы:

  1. $btn_continue = '<div class="changestatus_btn changestatus_continue" onclick="statusOffContinue(\''.$ctype_id.'\', '.$item_id.', \''.$field_id.'\')">Продолжить</div>';
  2. $btn_cancel = '<div class="changestatus_btn changestatus_cancel" onclick="statusCancel(\''.$ctype_id.'\', '.$item_id.', \''.$field_id.'\')">Отменить</div>';
  3. $result['text'] = '<div class="changestatus_modal changestatus_modal_'.$ctype_id.'_'.$item_id.'_'.$field_id.'"><div class="changestatus_modal_body">'.$label.$result_text.$buttons.'</div></div>';

В этом файле всё, перейдем к system/controllers/content/actions/changestatus_off_continue.php.

0
Нифигаccе о-го-гошеньки Нифигаccе о-го-гошеньки 30 дней назад #

Начало там такое же, поэтому сделаем всё то же самое:

  1. <?php class actionContentChangestatusOffContinue extends cmsAction {
  2.  
  3. public function run($ctype_id, $item_id, $field_id, $reason_id) {
  4.  
  5. if ((int)$ctype_id <= 0 || (int)$item_id <= 0 || (int)$field_id <= 0) {
  6.  
  7. $result['action'] = 'error';
  8.  
  9. $result['text'] = 'Ай-я-яй, как не стыдно?';
  10.  
  11. echo json_encode($result);
  12.  
  13. $this->halt();
  14.  
  15. }
  16.  
  17. $ctype = $this->model->getContentType($ctype_id);
  18.  
  19. if (!$ctype) {
  20.  
  21. $result['action'] = 'error';
  22.  
  23. $result['text'] = 'Плохо, очень плохо!';
  24.  
  25. echo json_encode($result);
  26.  
  27. $this->halt();
  28.  
  29. }
  30.  
  31. $ctype_name = $ctype['name'];
  32.  
  33. $field = $this->model->getItemById('con_'.$ctype_name.'_fields', $field_id);
  34.  
  35. if (!$field || $field['type'] != 'changestatus') {
  36.  
  37. $result['action'] = 'error';
  38.  
  39. $result['text'] = 'А ну, марш в угол!';
  40.  
  41. echo json_encode($result);
  42.  
  43. $this->halt();
  44.  
  45. }
  46.  
  47. $field_name = $field['name'];

Но в этот экшен приходит еще значение выпадающего списка с причиной. Проверим и его. Находим этот код:

  1. $reason = 'Причина не указана';
  2.  
  3. if ($reason_id != 'x') {
  4.  
  5. $field = $this->model->getItemByField('con_'.$ctype_name.'_fields', 'name', $field_name);
  6.  
  7. $field_options = $this->model->yamlToArray($field['options']);
  8.  
  9. $reasons = $field_options['reason'] ? preg_split('/\\r\\n?|\\n/', $field_options['reason']) : '';
  10.  
  11. if ($reasons) {
  12.  
  13. $reason = $reasons[$reason_id];
  14.  
  15. }
  16.  
  17. }

И немного его исправляем в этом месте:

  1. if ($reasons) {
  2.  
  3. $reason = $reasons[$reason_id];
  4.  
  5. }

Проверим, существует ли значение массива с полученным id:

  1. if ($reasons && isset($reasons[$reason_id])) {
  2.  
  3. $reason = $reasons[$reason_id];
  4.  
  5. }

Вот и всё. Кажется, опасность миновала)) Добавил в каталог дополнений исправленную версию.

+3
Loadырь Loadырь 30 дней назад #

Я специально сделал не так, как предложил Fuze, чтобы послушать критику по этому поводу. Как говорится, век живи — век учись))

Ну так слушайте ))):

Ваш новый код:

  1. if (!isset($this->item) || !$this->item['id'] || !$this->item['ctype_name'] || !$this->item['user_id']) {
  2.  
  3. return '';
  4.  
  5. }

Не решает проблему с нотисами. $this->item может сушествовать, но может не содержать id и прочих элементов массива. Как говорил Fuze надо делать так

  1. if (empty($this->item['id'])) {
  2.  
  3. return '';
  4.  
  5. }

empty не выдает нотисов при отсутсвии элементов массива, но при этом вернет false если $this->item не существует. То есть вы одним условием проверяете сразу оба варианта.

$this->item['user_id'] можно не проверять, так как оно всегда идет в $this->item, если есть $this->item['id'].

$this->item['ctype_name'] в последних версиях движка тоже нет смысла проверять (оно есть, если есть $this->item['id'] даже в профилях пользователей), но вы его впоследствии отбросили.

То же самое тут

  1. if ($reasons && isset($reasons[$reason_id])) {
  2.  
  3. $reason = $reasons[$reason_id];
  4.  
  5. }

Достаточно так

  1. if (!empty($reasons[$reason_id])) {
  2.  
  3. $reason = $reasons[$reason_id];
  4.  
  5. }

Начало там такое же, поэтому сделаем всё то же самое:

Дублирующийся код желательно выносить в отдельный метод/файл

0
Нифигаccе о-го-гошеньки Нифигаccе о-го-гошеньки 30 дней назад #

Спасибо за разъяснение, очень полезно!

Значит, вносим последние (но это не точно) правки в наше поле))

В файле system/fields/changestatus.php в методе parse() заменяем

  1. if (!isset($this->item) || !$this->item['id'] || !$this->item['ctype_name'] || !$this->item['user_id']) {
  2.  
  3. return '';
  4.  
  5. }


на это:

  1. if (empty($this->item['id'])) {
  2.  
  3. return '';
  4.  
  5. }


В файле system/controllers/content/actions/changestatus_off_continue.php заменяем

  1. if ($reasons && isset($reasons[$reason_id])) {
  2.  
  3. $reason = $reasons[$reason_id];
  4.  
  5. }


на это:

  1. if (!empty($reasons[$reason_id])) {
  2.  
  3. $reason = $reasons[$reason_id];
  4.  
  5. }


Теперь мы понимаем, как надо делать, и как делать не стоит.

А если есть еще замечания и/или возражения, с удовольствием принимаю любую конструктивную критику. Спасибо!))

+7
Fuze Fuze 29 дней назад #

Как и обещал, вот разбор одного из экшенов.

Код переделанного экшена changestatus_off_continue с комментариями:

  1. <?php
  2.  
  3. class actionContentChangestatusOffContinue extends cmsAction {
  4.  
  5. public function run($ctype_id, $item_id, $field_id, $reason_id) {
  6.  
  7. // Сразу объявляем переменную с результатами и возможными ячейками
  8. // Для улучшения читаемости и понимания что там может быть
  9. $result = [
  10. 'action' => 'error',
  11. 'text' => ''
  12. ];
  13.  
  14. // Сразу проверяем, что мы авторизованы
  15. // Нет никакого смысла выполнять код ниже, если мы не авторизованы
  16. if (!$this->cms_user->is_logged) {
  17.  
  18. $result['text'] = 'Требуется авторизация';
  19.  
  20. return $this->cms_template->renderJSON($result);
  21. }
  22.  
  23. // Проверяем что там числа, а не пытаемся привести к типу
  24. // Валидация предполагает проверку БЕЗ изменения данных
  25. if (!is_numeric($ctype_id) ||
  26. !is_numeric($item_id) ||
  27. !is_numeric($field_id) ||
  28. !is_numeric($reason_id)) {
  29.  
  30. // Пишем текст ошибки
  31. $result['text'] = 'Ай-я-яй, как не стыдно?';
  32.  
  33. // Для возврата JSON есть системный метод
  34. // Метод печатает JSON и завершает работу
  35. // пишем return чтобы читающему код было понятно, что здесь прерывается работа метода
  36. return $this->cms_template->renderJSON($result);
  37. }
  38.  
  39. // Т.к. этот экшен мы положили в контроллер content
  40. // То у нас доступна модель этого контроллера через $this->model
  41. $ctype = $this->model->getContentType($ctype_id);
  42.  
  43. // Проверяем что по переданному id есть тип контента
  44. if (!$ctype) {
  45.  
  46. $result['text'] = 'Плохо, очень плохо!';
  47.  
  48. return $this->cms_template->renderJSON($result);
  49. }
  50.  
  51. // Получаем поле - для этого есть готовый метод
  52. $field = $this->model->getContentField($ctype['name'], $field_id);
  53. // Поля нет или неверный тип поля
  54. if (!$field || $field['type'] !== 'changestatus') {
  55.  
  56. $result['text'] = 'А ну, марш в угол!';
  57.  
  58. return $this->cms_template->renderJSON($result);
  59. }
  60.  
  61. // Получаем запись, для этого тоже есть свой метод
  62. $item = $this->model->getContentItem($ctype['name'], $item_id);
  63. // Нет записи
  64. if (!$item) {
  65.  
  66. $result['text'] = 'Ну всё, приплыли...';
  67.  
  68. return $this->cms_template->renderJSON($result);
  69. }
  70.  
  71. // Если пользователь не админ и не автор записи
  72. if (!$this->cms_user->is_admin && $this->cms_user->id != $item['user_id']) {
  73.  
  74. $result['text'] = 'Вам запрещено это делать!';
  75.  
  76. return $this->cms_template->renderJSON($result);
  77. }
  78.  
  79. // Далее все разрешено, поэтому меняем действие
  80. $result['action'] = 'ok';
  81. // И пишем текст по умолчанию
  82. $result['text'] = 'Не актуально!';
  83.  
  84. // Причина по умолчанию
  85. $reason = 'Причина не указана';
  86.  
  87. // Раньше в коде было
  88. // if ($reason_id != 'x') {
  89. // Что весьма странное решение
  90. // Если мы решили, что $reason_id - это число, да и название этой переменной
  91. // Говорит нам что предполагается число
  92. // То неуказанный $reason_id стоит делать нулём
  93. // в JS файле замените на var reason = 'x'; на var reason = 0;
  94. // Проверяем, что $reason_id есть и больше нуля
  95. // Да, такая проверка делает именно это
  96. if ($reason_id) {
  97.  
  98. // Поле мы получили ранее и делать это еще раз как минимум странно
  99. // Поэтому я убрал получение поля еще раз
  100. // Теперь получаем причины
  101. // Вот так было ранее
  102. // $reasons = $field['options']['reason'] ? preg_split('/\\r\\n?|\\n/', $field['options']['reason']) : '';
  103. // Но лучше это сделать иначе
  104. // Пустой массив по умолчанию
  105. $reasons = [];
  106. // Если опция есть
  107. if (!empty($field['options']['reason'])) {
  108.  
  109. // Для распарсивания строк есть функция в движке
  110. $reasons = string_explode_list($field['options']['reason']);
  111. }
  112.  
  113. // Теперь проверяем, что переданный id причины есть
  114. if (!empty($reasons[$reason_id])) {
  115. $reason = $reasons[$reason_id];
  116. }
  117. }
  118.  
  119. // Если меняем под администратором, то причина всегда такая
  120. if ($this->cms_user->is_admin) {
  121. $reason = 'Статус изменен решением администрации сайта';
  122. }
  123.  
  124. // Обновляем запись
  125. // Таблица типа контента
  126. $table_name = $this->model->getContentTypeTableName($ctype['name']);
  127.  
  128. $this->model->update($table_name, $item['id'], [
  129. $field['name'] => $reason
  130. ]);
  131.  
  132. // И если совсем по феншую, то скидываем кэш, если включен
  133. cmsCache::getInstance()->clean('content.list.' . $ctype['name']);
  134. cmsCache::getInstance()->clean('content.item.' . $ctype['name']);
  135.  
  136. return $this->cms_template->renderJSON($result);
  137. }
  138.  
  139. }
  140.  
0
Loadырь Loadырь 29 дней назад #

Тут если админ, то код с 96 по 117 строки можно пропустить не выполняя.

  1. // Если меняем под администратором, то причина всегда такая
  2. if ($this->cms_user->is_admin) {
  3. $reason = 'Статус изменен решением администрации сайта';
  4. } elseif ($reason_id) {
  5.  
  6. // Поле мы получили ранее и делать это еще раз как минимум странно
  7. // Поэтому я убрал получение поля еще раз
  8. // Теперь получаем причины
  9. // Вот так было ранее
  10. // $reasons = $field['options']['reason'] ? preg_split('/\\r\\n?|\\n/', $field['options']['reason']) : '';
  11. // Но лучше это сделать иначе
  12. // Пустой массив по умолчанию
  13. $reasons = [];
  14. // Если опция есть
  15. if (!empty($field['options']['reason'])) {
  16.  
  17. // Для распарсивания строк есть функция в движке
  18. $reasons = string_explode_list($field['options']['reason']);
  19. }
  20.  
  21. // Теперь проверяем, что переданный id причины есть
  22. if (!empty($reasons[$reason_id])) {
  23. $reason = $reasons[$reason_id];
  24. }
  25. }
  26.  

Оптимизация 😄

0
Fuze Fuze 29 дней назад #

Так чутка грязнее всё же, да и экономия на спичках)

Еще от автора

Поле "UpJump - поднятие записей"
Поднятие авторами своих записей в списках типов контента и групп.
Компонент "UpJump - продвижение записей в списках" стал доступнее
И снова делаю то, о чем вы просили. Теперь речь про компонент "UpJump - продвижение записей в списках" - теперь он стал доступнее.
Компонент Google Indexing дешевле!
Как вы и просили, теперь компонент стал доступнее. Но есть нюансы.
Используя этот сайт, вы соглашаетесь с тем, что мы используем файлы cookie.