К содержанию

Готовые решения

Написать заметку

Временный плагин: лечим ошибку 403 при блокировке multipart/form-data хостером

Решение ошибки 403 Forbidden при сохранении документов в MODX (Хостинг Beget)



После недавних новостей о критической уязвимости в PHP, хостинг-провайдер Beget превентивно ввел жесткие ограничения безопасности (Уязвимость в PHP от 21.10.2026). В частности, хостер временно ограничил обработку POST-запросов типа
multipart/form-data
.
Читать далее, Временный плагин: лечим ошибку 403 при блокировке multipart/form-data хостером

Скрипты для создания регистрации, авторизации и сброса пароля для Ajax и не только

При написании проекта на MODX 3.2.0 потребовались регистрация, авторизация и сброс пароля с Ajax, а также связь с другими таблицами базы данных.
В итоге получилось реализовать без компонентов Login и ajaxLogin.

В данной заметке решил выложить скрипты в виде шпаргалки:

Читать далее, Скрипты для создания регистрации, авторизации и сброса пароля для Ajax и не только

Кастомная система ручных скидок менеджера в админке (с фиксацией исходной цены и скрытыми метками клиентов)

Кастомная система ручных скидок менеджера в админке (с фиксацией исходной цены и скрытыми метками клиентов)
Возникала необходимость вручную предоставить клиенту персональную скидку при обработке заказа в панели управления. Стандартный miniShop2 позволяет просто переписать поле «Стоимость» (cost), но у этого подхода есть две проблемы:

Человеческий фактор:
Если менеджер ошибся или скидку нужно изменить, исходная стоимость заказа теряется. Повторный пересчет идет от уже уменьшенной суммы.
Скрытые пометки:
Менеджеру нужно видеть статус клиента (например, «Постоянный покупатель 10%»), но выводить это в имя пользователя нельзя — клиент увидит пометку в системных письмах.

В этой статье мы создадим изолированную систему ручных скидок (от 1 до 20%) с защитой от перезаписи базовой цены, возможностью полной отмены скидки (сброс на 0) и выводом скрытых служебных заметок из профиля пользователя.

Шаг 1. Подготовка базы данных

Для хранения процента скидки и неприкосновенной первоначальной стоимости добавим две колонки в таблицу заказов modx_ms2_orders.
Выполните SQL-запрос

ALTER TABLE `modx_ms2_orders` 
ADD `discount_percent` INT(3) NOT NULL DEFAULT '0',
ADD `discount_original_cost` DECIMAL(15,2) NOT NULL DEFAULT '0.00';
Шаг 2. Серверный обработчик (PHP)

Создаем файл по адресу assets/components/minishop2/discount.php. Этот скрипт изолирован от ядра, работает через подготовленные выражения PDO, защищает исходную цену от изменений при повторных пересчетах, а при отправке 0 полностью откатывает заказ к исходному состоянию. Также он умеет читать скрытое поле comment из профиля покупателя.

<?php
define('MODX_API_MODE', true);
$modxRoot = '/var/www/www-root/data/www/site.ru/';
/**
обратите внимание на путь! возможно Вам подойдет конструкция ниже
$modxRoot = dirname(__DIR__, 3) . '/'; 
if (!file_exists($modxRoot . 'config.core.php')) {
    $modxRoot = $_SERVER['DOCUMENT_ROOT'] . '/';
}
**/
require_once $modxRoot . 'config.core.php';
require_once MODX_CORE_PATH . 'model/modx/modx.class.php';

$modx = new modX();
$modx->initialize('mgr');

header('Content-Type: application/json; charset=utf-8');

if (!$modx->hasPermission('save_document')) {
    http_response_code(403);
    echo json_encode(['success' => false, 'message' => 'Нет прав'], JSON_UNESCAPED_UNICODE);
    exit;
}

$orderId = isset($_POST['order_id']) ? (int)$_POST['order_id'] : 0;
$percent = isset($_POST['percent']) ? (int)$_POST['percent'] : 0;

if (!$orderId) {
    echo json_encode(['success' => false, 'message' => 'Не указан ID заказа'], JSON_UNESCAPED_UNICODE);
    exit;
}

$table = $modx->getTableName('msOrder');

$stmt = $modx->prepare("SELECT id, user_id, cost, discount_original_cost, discount_percent FROM {$table} WHERE id = ? LIMIT 1");
$stmt->execute([$orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$order) {
    echo json_encode(['success' => false, 'message' => 'Заказ не найден'], JSON_UNESCAPED_UNICODE);
    exit;
}

// Извлечение комментария из профиля покупателя
$userComment = '';
if (!empty($order['user_id'])) {
    $userTable = $modx->getTableName('modUserProfile');
    $userStmt = $modx->prepare("SELECT comment FROM {$userTable} WHERE internalKey = ? LIMIT 1");
    $userStmt->execute([$order['user_id']]);
    $userProfile = $userStmt->fetch(PDO::FETCH_ASSOC);
    if ($userProfile && !empty($userProfile['comment'])) {
        $userComment = trim($userProfile['comment']);
    }
}

if ($percent === -1) {
    echo json_encode([
        'success' => true,
        'order_id' => $orderId,
        'original_cost' => (float)$order['discount_original_cost'],
        'discount_percent' => (int)$order['discount_percent'],
        'cost' => (float)$order['cost'],
        'user_comment' => $userComment
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

if ($percent < 0 || $percent > 10) {
    echo json_encode(['success' => false, 'message' => 'Скидка должна быть от 0 до 10%'], JSON_UNESCAPED_UNICODE);
    exit;
}

$originalCost = (float)$order['discount_original_cost'];
if ($originalCost <= 0) {
    $originalCost = (float)$order['cost'];
}

if ($originalCost <= 0) {
    echo json_encode(['success' => false, 'message' => 'Стоимость заказа равна 0'], JSON_UNESCAPED_UNICODE);
    exit;
}

// Если менеджер ввел 0 — сбрасываем и очищаем скидку
if ($percent === 0) {
    $newCost = $originalCost;
    $resetOriginalCost = 0;

    $update = $modx->prepare("UPDATE {$table} SET discount_original_cost = ?, discount_percent = ?, cost = ? WHERE id = ? LIMIT 1");
    $result = $update->execute([$resetOriginalCost, $percent, $newCost, $orderId]);
} else {
    $discount = round($originalCost * $percent / 100, 2);
    $newCost = round($originalCost - $discount, 2);

    $update = $modx->prepare("UPDATE {$table} SET discount_original_cost = ?, discount_percent = ?, cost = ? WHERE id = ? LIMIT 1");
    $result = $update->execute([$originalCost, $percent, $newCost, $orderId]);
}

if (!$result) {
    http_response_code(500);
    echo json_encode(['success' => false, 'message' => 'Не удалось сохранить скидку'], JSON_UNESCAPED_UNICODE);
    exit;
}

echo json_encode([
    'success' => true,
    'order_id' => $orderId,
    'original_cost' => $originalCost,
    'discount_percent' => $percent,
    'cost' => $newCost,
    'user_comment' => $userComment
], JSON_UNESCAPED_UNICODE);

Читать далее, Кастомная система ручных скидок менеджера в админке (с фиксацией исходной цены и скрытыми метками клиентов)

Плагин EnvBadge: визуальный индикатор окружения в админке MODX3

Плагин EnvBadge: визуальный индикатор окружения в админке MODX3
Если вы работаете с несколькими копиями одного сайта — боевой (прод), разработка (dev) и тестовый (staging) — то наверняка сталкивались с ситуацией, когда в куче открытых вкладок легко перепутать, где именно вы находитесь. Правки, сделанные «не на том» домене, в лучшем случае теряются, а в худшем ломают живой сайт.

Этот небольшой плагин решает проблему просто и наглядно: он выводит в админке цветной бейдж с названием текущего окружения. Открыли боевой сайт — сверху красная полоса «ПРОД», зашли на разработку — зелёная «РАЗРАБОТКА», на тесте — оранжевая «СТЕЙДЖИНГ». Ошибиться уже не получится: статус виден с первого взгляда, ещё до того как вы начали что-то менять.





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

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

Читать далее, Плагин EnvBadge: визуальный индикатор окружения в админке MODX3

Уведомления о заказах minishop в MAX

Здравствуйте. Хочу поделиться плагином, на замену старому плагину уведомлений о заказах Minishop2 в телеграмм. Поиском поискал, кажется такой правки не было, может кому пригодится.

Плагин у меня работает, уведомления приходят в паблик или чат. Можно использовать одного MAX бота для подключения разных магазинов для разных клиентов в разные чаты.
Читать далее, Уведомления о заказах minishop в MAX

[CLI Package Builder] Инфраструктура для тестов

Всем привет! Обновление Package Builder — v0.4.1. Теперь сборщик умеет создавать тестовую инфраструктуру для компонентов прямо при генерации пакета. Можно проверять логику своего кода без установленного MODX и без базы данных — прямо на локальной машине.
Читать далее, [CLI Package Builder] Инфраструктура для тестов

[CLI Package Builder] Разработка пакетов без рутины

Всем привет! Рад представить Package Builder — CLI-инструмент для быстрого старта разработки пакетов под MODX Revolution 3. Это мой первый пакет, опубликованный на Packagist, и установить его можно одной командой: composer global require shevartv/modx-builder.
Читать далее, [CLI Package Builder] Разработка пакетов без рутины

mSync Cron Integration

Может кому пригодится, для тех кто использует mSync и хочет себе крон))

Инструкция по настройке автоматического импорта через CRON товаров для компонента mSync

Установка

1. Перейдите в директорию: `assets/components/msync/`
2. Найдите там существующий файл `1c_exchange.php`.
3. Замените его содержимое новым кодом (обновленной версией скрипта с поддержкой CLI).

Важно: Перед заменой рекомендуется сделать резервную копию оригинального файла.
Читать далее, mSync Cron Integration

Обновление полей ресурсов на основе csv-файла иморта (modx 2.8.8 + minishop 2)

Задача была обновить поля у ресурсов (ресурсы: категории, товары; поля: основные, дополнительные (тв)).
Обновление изображений (галерея) в задачу не входило.
Возможно, кому-нибудь пригодится. Если будут корректировки/замечания, буду только рад.
Читать далее, Обновление полей ресурсов на основе csv-файла иморта (modx 2.8.8 + minishop 2)

Видеогалерея на MIGX. Может кому понадобится (решено)

*Помогите толком разобраться, как редактировать поля title, desc, image, duration, которые находятся внутри json массива у видео в виде отдельных полей, а не кривыми ручками

Видеогалерея на MIGX

  1. Устанавливаем VideoGallery
  2. Создаем tv с именем video тип ввода VideoGallery
  3. Создаем tv с именем videogallery. Тип ввода migx, конфигурация videogallery, выбираем шаблон для отображения
  4. Создаем сниппет VideoJsonToPlaceholders
    ! ВНИМАНИЕ в п.2 удалите пробел между & quot;

    <?php
    // Получаем параметры
    $json = $modx->getOption('json', $scriptProperties, '');
    $prefix = $modx->getOption('prefix', $scriptProperties, 'json.');
    
    // 1. Проверяем наличие данных
    if (empty($json)) {
        return 'Ошибка: JSON‑строка пуста';
    }
    
    // 2. Удаляем все & quot; из строки ! Внимание в следующей строке удалите пробел между & quot;
    $json = str_replace('& quot;', '', $json);
    
    // 3. Исправляем экранированные слеши
    $json = str_replace('\\/', '/', $json);
    
    // 4. Парсим JSON
    $data = json_decode($json, true);
    
    if (json_last_error() !== JSON_ERROR_NONE) {
        return 'Ошибка JSON: ' . json_last_error_msg() . ' (строка: ' . htmlspecialchars($json) . ')';
    }
    
    // 5. Проверяем, что результат — массив
    if (!is_array($data)) {
        return 'Ошибка: JSON не содержит массив';
    }
    
    // 6. Функция для извлечения корневого адреса (с защитой от повторного объявления)
    if (!function_exists('getRootUrl')) {
        function getRootUrl($url) {
            if (!filter_var($url, FILTER_VALIDATE_URL)) {
                return null;
            }
    
            $parsed = parse_url($url);
            if (!$parsed || !isset($parsed['scheme']) || !isset($parsed['host'])) {
                return null;
            }
    
            $root = $parsed['scheme'] . '://' . $parsed['host'];
    
            if (isset($parsed['port'])) {
                $isDefaultHttp = ($parsed['scheme'] === 'http' && $parsed['port'] === 80);
                $isDefaultHttps = ($parsed['scheme'] === 'https' && $parsed['port'] === 443);
                if (!$isDefaultHttp && !$isDefaultHttps) {
                    $root .= ':' . $parsed['port'];
                }
            }
    
            return $root;
        }
    }
    
    // 7. Устанавливаем плейсхолдеры с обработкой
    foreach ($data as $key => $value) {
        // Приводим строки к UTF‑8
        if (is_string($value)) {
            $value = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
        }
    
        // Если это поле video и содержит URL — извлекаем корневой адрес
        if ($key === 'video' && !empty($value)) {
            $rootUrl = getRootUrl($value);
            if ($rootUrl) {
                $modx->setPlaceholder($prefix . 'video_root', $rootUrl);
            }
            // Сохраняем исходный URL
            $modx->setPlaceholder($prefix . 'video', $value);
        }
    
        // Преобразуем videoDuration из PT... в оптимальный формат
        if ($key === 'videoDuration') {
            if (empty($value) || !is_string($value)) {
                $value = '0 с';
            } else {
                preg_match('/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/', $value, $matches);
                $hours   = isset($matches[1]) ? (int)$matches[1] : 0;
                $minutes = isset($matches[2]) ? (int)$matches[2] : 0;
                $seconds = isset($matches[3]) ? (int)$matches[3] : 0;
    
                if ($hours > 0) {
                    $value = sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds);
                } elseif ($minutes > 0) {
                    $value = sprintf('%02d:%02d', $minutes, $seconds);
                } else {
                    $value = $seconds . ' с';
                }
            }
        }
    
        // Подставляем значения по умолчанию для критических полей
        switch ($key) {
            case 'title':
                if (empty($value)) {
                    $value = 'Без названия';
                }
                break;
            case 'desc':
                if (empty($value)) {
                    $value = 'Описание отсутствует';
                }
                break;
        }
    
        // Устанавливаем плейсхолдер (если он ещё не установлен через парсинг)
        if (!in_array($key, ['video', 'video_root'])) {
            $modx->setPlaceholder($prefix . $key, $value);
        }
    }
    
    return '';
  5. Создаем новое MIGx поле videogallery
  6. Экспортируем туда конфиг
    {
      "formtabs": [
        {
          "caption": "Видеогалерея",
          "print_before_tabs": "0",
          "fields": [
            {
              "field": "video",
              "caption": "Видео",
              "inputTV": "video"
            }
          ],
          "pos": 1
        }
      ],
      "contextmenus": "",
      "actionbuttons": "",
      "columnbuttons": "",
      "filters": "",
      "extended": {
        "actionbuttonsperrow": 4,
        "gridload_mode": 1,
        "has_jointable": "yes"
      },
      "permissions": {},
      "fieldpermissions": "",
      "columns": [
        {
          "header": "Рендер",
          "dataIndex": "render",
          "width": 1,
          "sortable": "false",
          "show_in_grid": 0,
          "renderer": "this.renderChunk",
          "renderchunktpl": "[[!VideoJsonToPlaceholders? &json=`[[+video]]` &prefix=`video.`]]"
        },
        {
          "header": "Название",
          "dataIndex": "video_title",
          "sortable": "false",
          "show_in_grid": 1,
          "renderer": "this.renderChunk",
          "renderchunktpl": "[[+video.title]]"
        },
        {
          "header": "Видео",
          "dataIndex": "video_video",
          "sortable": "false",
          "show_in_grid": 1,
          "renderer": "this.renderChunk",
          "renderchunktpl": "<iframe src=\"[[+video.video]]\" width=\"160\" height=\"90\" frameborder=\"0\"></iframe>"
        },
        {
          "header": "Превью",
          "dataIndex": "video_image",
          "sortable": "false",
          "show_in_grid": 1,
          "renderer": "this.renderChunk",
          "renderchunktpl": "<img src=\"[[+video.image]]\" width=\"160\">"
        },
        {
          "header": "Время",
          "dataIndex": "video_duration",
          "width": 100,
          "sortable": "false",
          "show_in_grid": 1,
          "renderer": "this.renderChunk",
          "renderchunktpl": "[[+video.videoDuration]]"
        },
        {
          "header": "videoId",
          "dataIndex": "video_videoId",
          "sortable": "false",
          "show_in_grid": "0",
          "renderer": "this.renderChunk",
          "renderchunktpl": "[[+video.videoId]]"
        },
        {
          "header": "Описание",
          "dataIndex": "video_description",
          "sortable": "false",
          "show_in_grid": 1,
          "renderer": "this.renderChunk",
          "renderchunktpl": "[[+video.desc]]"
        }
      ],
      "category": ""
    }
Комментарии →