Calendar 26.300.0: закрыты дыры в правах на календари и переговорки, дельта в REST
Обновление безопасности
Закрыта уязвимость или ослабленная проверка прав. Ставить в первую очередь.
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. До принятия она время не занимает. Как объясняет комментарий в коде, синхронизация ящика создаёт событие уже при доставке письма, и иначе любой отправитель мог бы забить гостю сетку занятости.