calendar · Календарь событий 26.300.0 Безопасность

Calendar 26.300.0: закрыты дыры в правах на календари и переговорки, дельта в REST

2 мин чтения
Узнавайте о ломающих изменениях Битрикса раньше, чем они доедут до прода: свежие разборы — в канале @bxmax_updates

Обновление безопасности

Закрыта уязвимость или ослабленная проверка прав. Ставить в первую очередь.

Calendar 26.300.0 закрывает три дыры в правах: перенос события в чужой календарь без права на добавление, подмену чужой брони переговорки через LOCATION['OLD'] или room_event_id и добавление в участники проектов, где пользователь не состоит. Для интеграций в calendar.event.get появилось инкрементальное чтение. Тем, кто сохраняет события из PHP, придётся поправить код: CCalendar::SaveEvent бросает новое исключение и без явного флага больше не проверяет занятость переговорок.

Новое

Забирайте из calendar.event.get только изменения

У calendar.event.get появился параметр modified_since. С ним метод вернёт события, изменённые начиная с этого момента, и удалённые тоже: они приходят надгробиями с DELETED = 'Y' и временем удаления в TIMESTAMP_X. Без параметра ответ прежний, только живые события. Формат строгий, ISO-8601 с явной зоной или Z: 2026-10-28T12:00:00+03:00, доли секунды допускаются. Значение без зоны, null, не строка или несуществующая дата вроде 2026-02-30T12:00:00Z дают ERROR_ARGUMENT, пустая строка значит «без границы». Запрос с границей строится из базы, кеш списка событий он не использует.

Курсор берите из нового поля TIMESTAMP_X_ISO, его значение передаётся обратно в modified_since без преобразований. Рядом появилось DATE_CREATE_ISO. Оба поля отдают calendar.event.get, calendar.event.getbyid и calendar.event.get.nearest со смещением портала, а если дату разобрать не удалось, ключа в ответе нет. На DATE_FROM_TS_UTC и DATE_TO_TS_UTC как на UTC не рассчитывайте: в докблоке метода написано, что там смещение сервера. Граница включительная, так что записи той секунды, на которой стоит курсор, придут ещё раз. Удалённая серия приходит записью на каждый повтор в окне, все под одним ID. По замеру из докблока один мастер серии дал 52 записи. Сворачивайте ответ по ID:

        <?php

declare(strict_types=1);

namespace Vendor\CalendarSync;

use Bitrix\Main\Web\HttpClient;
use Bitrix\Main\Web\Json;

final class CalendarDeltaReader
{
    public function __construct(
        private readonly string $webhookUrl, // https://portal.example/rest/1/<code>/
    ) {
    }

    /**
     * @return array{changed: array<int, list<array>>, deleted: list<int>, cursor: string}
     */
    public function read(int $ownerId, string $cursor): array
    {
        $raw = (new HttpClient())->post($this->webhookUrl . 'calendar.event.get.json', [
            'type' => 'user',
            'ownerId' => $ownerId,
            'modified_since' => $cursor, // '' при первом запуске: всё окно, без надгробий
        ]);
        if ($raw === false) {
            throw new \RuntimeException('calendar.event.get: портал не ответил');
        }

        $response = Json::decode($raw);
        if (isset($response['error'])) {
            throw new \RuntimeException($response['error'] . ': ' . ($response['error_description'] ?? ''));
        }

        $changed = [];
        $deleted = [];
        $nextCursor = $cursor;
        $cursorMoment = $cursor === '' ? null : new \DateTimeImmutable($cursor);

        foreach ($response['result'] ?? [] as $event) {
            $id = (int)$event['ID'];
            if (($event['DELETED'] ?? 'N') === 'Y') {
                $deleted[$id] = $id;
            } else {
                $changed[$id][] = $event;
            }

            if (isset($event['TIMESTAMP_X_ISO'])) {
                $mark = new \DateTimeImmutable($event['TIMESTAMP_X_ISO']);
                if ($cursorMoment === null || $mark > $cursorMoment) {
                    $cursorMoment = $mark;
                    $nextCursor = $event['TIMESTAMP_X_ISO'];
                }
            }
        }

        return ['changed' => $changed, 'deleted' => array_values($deleted), 'cursor' => $nextCursor];
    }
}

    

Дельта работает только внутри окна from—to: удаление события за пределами окна не придёт. Ещё она не показывает удаление календаря целиком, удаление события переговорки и ответы участников. Ответ гостя меняет только его статус, TIMESTAMP_X события остаётся прежним. Если синхронизации нужны статусы участников, периодически перечитывайте окно без modified_since: статусы гостей приходят в ATTENDEE_LIST.

В PHP ту же выборку собирает CCalendar::GetEventList() с ключом modifiedSince (объект Bitrix\Main\Type\DateTime). Если идёте напрямую в CCalendarEvent::GetList, границу задаёт фильтр TIMESTAMP_X_FROM. Он принимает только DateTime и на остальное бросает ArgumentTypeException, а надгробия придут, только если добавить 'DELETED' => false.

Приглашения по почте идут через Main Messenger

Письма-приглашения отправляются через очередь calendar_email_invitation с брокером calendar_db, она объявлена в .settings.php модуля. Если почта не приняла письмо, получатель очереди делает ещё две попытки с паузой из retry_strategy, а после третьей неудачи шлёт организатору уведомление fail_ical_invite. Недоставленные приглашения из старой очереди календаря переносит в Messenger степпер EmailInvitationQueueDrain. Вернуться на старый путь можно опцией email_invitation_messenger модуля calendar со значением N.

Что сломается

Ловите EventSaveStateException вокруг SaveEvent

CCalendar::SaveEvent(), CCalendar::SaveEventEx() и CCalendarEvent::Edit() теперь бросают Bitrix\Calendar\Core\Event\EventSaveStateException, наследника \RuntimeException. Его getErrorCode() отдаёт одну из двух констант. EVENT_NOT_AVAILABLE (CALENDAR_EVENT_NOT_AVAILABLE) значит, что событие есть, но пользователю недоступно. ROOM_RESERVATION_NOT_AVAILABLE (CALENDAR_ROOM_RESERVATION_NOT_AVAILABLE) прилетает, когда бронь из LOCATION не найдена или лежит в другой переговорке, переговорка неактивна или без имени либо у пользователя нет прав view_time и view_full на неё. Если события в базе нет совсем, методы возвращают false. Rooms\Manager::reserveRoom() с room_event_id недоступной брони тоже бросает это исключение, раньше он возвращал null.

Код, который вокруг сохранения ловит только OccupancyCheckerException и OccupancyLockedException, получит необработанное исключение.

В REST calendar.event.add отдаёт оба кода как RestException. calendar.event.update на недоступное событие отвечает как раньше: пустой error и текст «При изменении события произошла ошибка». На недоступную бронь он отвечает кодом CALENDAR_ROOM_RESERVATION_NOT_AVAILABLE.

Передавайте checkLocationOccupancy явно

В 26.200.0 PHP API начал по умолчанию проверять занятость переговорки (разбор). В 26.300.0 этот дефолт убрали. Без ключа checkLocationOccupancy ни CCalendar::SaveEvent(), ни CCalendarEvent::Edit() не ищут пересечения брони и не бросают OccupancyCheckerException. CCalendarEvent::checkLocationOccupancyBatch() с $throwOnConflict = null проверяет только права на переговорки и возвращает false. Двойное бронирование, которое ваш код на 26.200 ловил исключением, теперь молча сохранится. REST, форма редактирования и перетаскивание события в сетке проверяют занятость как раньше.

Сохранение с явным флагом и тремя исключениями:

        <?php

declare(strict_types=1);

namespace Vendor\Booking\Service;

use Bitrix\Calendar\Core\Event\EventSaveStateException;
use Bitrix\Calendar\Rooms\OccupancyCheckerException;
use Bitrix\Calendar\Rooms\OccupancyLockedException;
use Bitrix\Main\Error;
use Bitrix\Main\Loader;
use Bitrix\Main\Result;

final class MeetingSaver
{
    public function save(array $fields, int $userId): Result
    {
        Loader::requireModule('calendar');
        $result = new Result();

        try {
            $eventId = \CCalendar::SaveEvent([
                'arFields' => $fields,
                'userId' => $userId,
                // с 26.300.0 без этого ключа пересечение брони не проверяется
                'checkLocationOccupancy' => 'Y',
            ]);
        } catch (EventSaveStateException $e) {
            // CALENDAR_EVENT_NOT_AVAILABLE или CALENDAR_ROOM_RESERVATION_NOT_AVAILABLE
            return $result->addError(new Error($e->getMessage(), $e->getErrorCode()));
        } catch (OccupancyLockedException $e) {
            return $result->addError(new Error('Переговорку сейчас бронирует кто-то ещё', $e->getErrorCode()));
        } catch (OccupancyCheckerException $e) {
            return $result->addError(new Error(
                $e->getMessage(),
                $e->isInvalidInterval()
                    ? OccupancyCheckerException::INVALID_INTERVAL_ERROR_CODE
                    : OccupancyCheckerException::ERROR_CODE,
            ));
        }

        if (!$eventId) {
            // false в том числе тогда, когда события с переданным ID в базе нет
            return $result->addError(new Error('Событие не сохранено', 'EVENT_NOT_SAVED'));
        }

        return $result->setData(['id' => (int)$eventId]);
    }
}

    

Не освобождайте чужую бронь через LOCATION[’OLD’]

CCalendarEvent::Edit() больше не берёт старое место из LOCATION['OLD']. Ядро берёт место из текущего события, которое читает из базы мимо кеша и с проверкой прав. Переданное OLD служит только ожидаемым местом для блокировки переговорок. Если OLD совпадает с местом события в базе, ничего не меняется. Сломается код, который передавал в OLD другое место, например чтобы освободить бронь соседнего события: ядро возьмёт старую бронь из базы, а не вашу. Передавайте новое место строкой или в NEW.

Мелочи и находки

  • Пользователи заметят два новых отказа. Перенести событие из формы редактирования в календарь без права на добавление больше нельзя, обработчик бросит PermissionDenied. Проект, где пользователь не владелец, не модератор и не участник, молча выпадает из участников при создании и редактировании, а уже сохранённые в событии такие проекты остаются.
  • Мягкое удаление обновляет TIMESTAMP_X, на этом держатся надгробия в REST. Если вы читали TIMESTAMP_X удалённых строк, теперь там момент удаления.
  • CCalendar::DeleteEvent() при занятой блокировке переговорок возвращает false, OccupancyLockedException наружу больше не выходит.
  • Входящее iCal-приглашение по почте раньше всегда показывалось свободным временем. После принятия встреча занимает время в планировщике, если в письме TRANSP не TRANSPARENT. До принятия она время не занимает. Как объясняет комментарий в коде, синхронизация ящика создаёт событие уже при доставке письма, и иначе любой отправитель мог бы забить гостю сетку занятости.

Новая версия вышла — разбор уже в Telegram

В канале @bxmax_updates публикуем каждый свежий разбор: что сломается, что устарело и что появилось в API. Узнаете до того, как обновите прод.

Подписаться

Читайте дальше

calendar 26.200.0 Ломающее Свежее

Calendar 26.150.0–26.200.0: двойная бронь переговорки стала ошибкой, а форма бронирования больше не отдаёт чужую занятость

Календарь вышел двумя версиями, 26.150.0 и 26.200.0. В 26.150.0 закрыли утечку в анонимном экшене бронирования CRM-формы, через который любой посетитель мог узнать занятость и имена сотрудников и ресурсов портала. В 26.200.0 занятость переговорок проверяется по умолчанию в `CCalendar::SaveEvent`, RE…

1 мин
calendar 26.125.100 Безопасность Свежее

Calendar 26.125.100: карточка события без полного доступа больше не выдаёт хоста, участников и раздел

В Calendar 26.125.100 карточка события и планировщик перестали отдавать пользователю без полного доступа хоста, автора, участников, данные раздела, ссылки и пользовательские поля. Режет данные новый класс `Bitrix\Calendar\View\EventViewData`, а ответы экшенов `getViewEventSlider`, `getCalendarViewSl…

5 мин
calendar 26.100.0 Безопасность

Calendar 26.100.0: почтовые приглашения и calendar.section.update берут хоста, тип и владельца из базы

В Calendar 26.100.0 REST-метод `calendar.section.update` берёт тип и владельца секции из базы, а обработчик входящих iCal-приглашений ищет событие только среди встреч получателя и при обновлении встречи читает её хоста из базы. В участники встречи теперь можно звать команды из модуля `humanresources…

4 мин

Calendar 26.125.0: компактная форма события вне календаря

Экшен `calendar.api.calendarajax.getStandaloneCompactFormData` собирает секции для формы заново. Обычному пользователю он гарантирует секцию для сохранения и при необходимости создаёт личный календарь. Коллаберу отдаёт только секции его коллабов с правом записи, экстранет-пользователю и коллаберу бе…

1 мин
Мы используем файлы cookie для улучшения работы сайта. Продолжая использовать сайт, вы соглашаетесь с нашей политикой конфиденциальности.
AI Домовой

AI Домовой История

на связи

пишет…

Можно закрыть чат и перейти на другую страницу. Когда ответ будет готов, на кнопке Домового появится отметка.

Нет истории чатов
AI Домовой

Нужна авторизация

Войдите, чтобы задавать вопросы AI Домовому.

Войти