Загрузки и превью ссылок
Blok нужно куда-то класть загруженные файлы и чем-то читать заголовок и картинку вставленной ссылки. Ни то, ни другое браузеру делать не разрешено.
Закрыть это можно тремя способами. Первый не требует запускать вообще ничего, так что начните с него. Найдите заголовок про вас и не читайте остальные два.
Сразу о честном ограничении превью ссылок
Превью ссылок работают примерно для 70% сайтов, ссылки на которые вставляют люди. X, Instagram, LinkedIn и многие новостные сайты не отдают данные для превью ничему, кроме браузера, а прочитать то, чего сайт не присылает, не может ни один сервис.
Если превью прочитать не удалось, блок остаётся обычной ссылкой с показом домена. Это не ошибка, и на самой вставке это никак не сказывается.
| Ваш случай | Маршрут в приложении | Запускает сервис? |
|---|---|---|
| У вас уже есть хранилище | Не нужен | Нет |
| Ваше приложение на ASP.NET Core | Да | Да |
| У вас свой бэкенд | Да | Да |
| Вы на Vercel или Netlify | Да | Да |
Прочитайте до развёртывания
Документы остаются вашими, а сервис держит только рабочую копию
Сохранение и загрузка документов остаются маленьким маршрутом в вашем собственном приложении, там же, где уже живёт ваша проверка прав. Именно этот маршрут остаётся записью. Он попадает в ваши резервные копии, и его вы чистите, когда человек просит удалить свои данные.
Совместное редактирование добавляет одну движущуюся часть. Пока люди редактируют вместе, сервис держит рабочую копию открытого документа в хранилище, которое вы ему укажете. Раз в несколько секунд он записывает набранное обратно в ваш маршрут.
Потеря рабочей копии стоит максимум нескольких секунд ещё не записанных изменений. Следующее открытие начнётся заново с вашего маршрута.
Эти же несколько секунд дают задержку и в обратную сторону. Ваша запись отстаёт от того, что видят люди в документе. Поэтому выгрузка, отчёт или другой сервис, читающий вашу базу, видит документ на момент последней записи. Только что нажатой клавиши он не видит.
Обратную запись, которую ваш маршрут отклонил, сервис не выбрасывает, а повторяет с растущей задержкой. Документ, чья последняя обратная запись ещё не легла, остаётся загруженным, пока она не ляжет, даже когда из него все вышли. Поэтому короткий сбой на вашей стороне стоит времени, а не правок.
Этот ритм принадлежит только рабочей копии. Зарегистрируйте журнал операций, и рабочей копии у документа не останется вовсе. Как именно, говорит запись ниже про безопасный повтор внешней правки.
С журналом правка становится надёжной в журнале. Запись у вашего маршрута обновляется опубликованной контрольной точкой. Это происходит при выгрузке комнаты из памяти и при остановке сервиса, а не по таймеру. Задержка выше тогда уже не несколько секунд, и её цену называет запись про границу отката.
Пока идёт совместное редактирование, ваше приложение само документ не сохраняет
Собственную опцию редактора persistence нельзя сочетать с collaboration. Это пара колбэков загрузки и сохранения, которыми одиночный редактор общается с вашим маршрутом.
Редактор отклоняет такую пару сразу при создании и говорит, что убрать. Иначе у документа окажется два владельца, которые пишут одну и ту же запись из двух мест.
В обмен ничего не теряется. Сервис загружает документ из вашего маршрута документов и записывает его обратно туда же. Значит, это всё равно ваш маршрут, только вызывает его сервис, а не браузер.
То же и с сохранением вручную. onChange и onSave продолжают срабатывать, пока люди редактируют вместе, в том числе на правки других людей. Так задумано, чтобы связанная с редактором форма оставалась актуальной.
Не записывайте то, что они вам отдают, обратно в свою запись. Теперь это работа сервиса, а второй пишущий только мешает ему.
Читайте это и в обратную сторону. Опция persistence записывает весь JSON документа целиком с задержкой. Когда сохранения накапливаются, уходит только самая свежая порция.
Каждое сохранение несёт документ целиком, поэтому набранное не теряется: его несёт самая свежая порция. Промежуточные состояния документа не записываются нигде. Это путь совместимости для сборки без сервиса, а не запись того, что происходило.
Там, где важна надёжность сохранения, выбирайте совместное редактирование. А что там значит «сохранено», говорит запись про состояние сохранения ниже.
Рабочая копия не должна быть публично читаемой
Пока люди редактируют вместе, рабочая копия лежит в папке, названной в --collab-dir. Она может лежать и под префиксом S3 из --collab-s3-prefix. В ней содержимое документов, поэтому читать её публично нельзя.
Загруженные файлы задуманы так, чтобы их отдавать любому по ссылке. Рабочая копия предназначена только самому сервису. По той же причине сервис отказывается класть её внутрь каталога загрузок: всё, что там лежит, раздаётся наружу.
Две вещи про папку, названную в --collab-dir.
- Файл, который сервис не может прочитать, переименовывается рядом с суффиксом .unreadable-.
- Документ после этого загружается заново с вашего маршрута, так что один испорченный файл никогда не заклинивает документ.
- Временные файлы, оставшиеся от процесса, который умер посреди записи, удаляются при запуске сервиса.
Где бы рабочая копия ни лежала, копия в формате, который этот сервис не читает, роняет открытие, а не толкуется наугад. Формат назван в журнале, и такой файл нужно убрать, прежде чем документ снова откроется.
Безопасный повтор внешней правки требует журнала операций
Два обещания про POST /sync/{doc}/edit выполняются, только если у сервиса есть журнал операций.
- Повтор с тем же Blok-Idempotency-Key возвращает первый результат, а не применяет правку второй раз.
- Использование того же ключа для другой работы отклоняется с 409.
Рабочая копия журналом не является. Ни папка, названная в --collab-dir, ни префикс, названный в --collab-s3-prefix, не хранят историю того, что в них лежит. Получить журнал можно двумя способами.
- На автономном хосте добавьте --collab-journal или задайте BLOK_COLLAB_JOURNAL=true.
- Встроенный журнал хранится на локальном диске, рядом с рабочей копией в --collab-dir.
- Флагу нужен --collab. Вместе с --collab-s3-prefix он отклоняется: второй экземпляр, работающий с тем же бакетом, не увидит журнал на диске этой машины.
- Поэтому он обслуживает один экземпляр. Внутри приложения тот же переключатель называется options.CollabJournal.
- Для нескольких экземпляров напишите хранилище сами.
- Внутри собственного приложения на ASP.NET Core реализуйте ICollabOperationStore поверх хранилища, которому вы уже доверяете транзакции.
- Зарегистрируйте его через AddBlokServer(...).UseCollabOperationStore<YourStore>().
- Зарегистрированное вами хранилище заменяет встроенное.
В обоих случаях первое открытие каждого документа берёт его рабочую копию за отправную точку журнала, а затем удаляет её. Ничего из того, что держала рабочая копия, не теряется.
Для окончательного удаления нужен ещё один интерфейс. Ваше хранилище должно также реализовать ICollabOperationPurgeStore. Иначе PurgeDocumentAsync бросит NotSupportedException раньше, чем что-либо удалит. Встроенный журнал его уже реализует.
Без журнала сервис работает только с рабочей копией. Внешняя правка тогда ведёт себя как любой другой повторяемый запрос. Она применяется, получает 204 и применяется ещё раз, если вы отправите её дважды.
С журналом каждое принятое изменение попадает в журнал раньше, чем о нём узнает кто-либо ещё. Поэтому изменение, дошедшее до вкладки, это изменение, пережившее падение процесса. А правку, которую журнал не может зафиксировать, сервис отклоняет с 503 и никому не передаёт, вместо 204 за сохранение, которого не было.
К хранилищу, которое вы пишете сами, сервис предъявляет строгие требования.
- Нужен один живой писатель на документ.
- Нужно чтение, видящее каждую более раннюю фиксацию.
- Данные должны быть надёжно записаны до возврата из вызова.
- Проверка дубля и присвоение номера идут одним шагом.
- Любая ошибка должна быть брошена наружу, а не проглочена.
Полный список README пакета сервера приводит рядом с рабочей регистрацией.
Отключение журнала операций не является откатом
Регистрация хранилища операций почти необратима, и так с каждым документом. У документа с журналом рабочей копии нет вовсе. Целиковый JSON у вашего эндпоинта обновляется только в трёх случаях: опубликованная контрольная точка, выгрузка комнаты из памяти и остановка с выгрузкой. На каждом окне правок он не обновляется.
Считайте это потолком, а не обещанием. Насколько запись свежа на самом деле, показывает проверка ниже.
Между этими моментами журнал опережает всё остальное, поэтому последние подтверждённые сервисом правки лежат там, куда сборка без вашего хранилища заглянуть не может. Снимите регистрацию хранилища или вернитесь на сборку, у которой его никогда не было, и документ откроется из того, что у него ещё осталось.
- У документа, который был с журналом с самого начала, рабочей копии нет.
- Он заново загружается с вашего эндпоинта и возвращается в состояние последней принятой эндпоинтом записи.
- Каждая подтверждённая с тех пор операция остаётся в вашем журнале, и никто её не отдаёт.
- Первое открытие под журналом берёт рабочую копию документа за отправную точку, а затем удаляет её.
- Документ, который работал под --collab-dir или --collab-s3-prefix и с регистрации хранилища ни разу не открывался, до сих пор держит рабочую копию того дня.
- Непустая рабочая копия побеждает при открытии, и ваш эндпоинт не опрашивается.
- Такой документ вернётся в состояние на день переключения, без ошибки и без единой строки в логе.
Blok не держит рядом с журналом вторую целиковую копию документа, чтобы возврат был мгновенным. Записью является журнал, а JSON лишь его проекция.
Перед откатом сделайте так.
- Остановите сервис с выгрузкой: она сбрасывает запись каждой открытой комнаты.
- Самостоятельный хост делает это при штатной остановке.
- Приложение внутри своего процесса вызывает ICollabRoomManager.DrainAsync.
- Затем проверьте по каждому документу, что последняя принятая эндпоинтом обратная запись несёт Blok-Doc-Sequence, равный durable-through в голове вашего журнала на той же Blok-Doc-Lineage.
- Всё, чего до него не хватает, откат теряет.
- Каждый документ, которому не хватило, откройте его один раз на новой сборке.
- Достаточно подключения только на чтение.
- Потом повторите остановку с выгрузкой и проверку.
- Загрузка комнаты с журналом помечает запись как должную, а у документа, чей процесс упал с непереданной записью, комнаты для выгрузки просто не было.
- Только после этого запускайте старую сборку, не впуская клиентов, пока это делаете.
Комната, поднятая из рабочей копии, версии документа ещё не знает. Её первая обратная запись уходит без Blok-Doc-Version, поэтому вашему эндпоинту нечем ответить 409. Один символ, набранный одним человеком до того, как ляжет reset, запишет документ дня переключения поверх той записи, которую вы только что проверили.
В конце вызовите по одному POST /sync/{doc}/reset для каждого документа, у которого рабочая копия была ещё до регистрации хранилища. Он опустошает копию, и следующее открытие берёт документ с вашего эндпоинта. Клиентов впускайте после того, как вернётся последний reset, и не раньше.
Дорога вперёд тоже односторонняя. Зарегистрируйте хранилище снова, и при открытии победит журнал, так что всё набранное, пока работала старая сборка, в него не попадёт.
Изменение документа снаружи: два вызова и когда какой
Пока документ открыт для совместного редактирования, побеждает рабочая копия. Изменение, записанное прямо в ваши записи, будет затёрто, как только сервис в следующий раз запишет документ обратно. Поэтому снаружи есть два пути, и для обоих нужен пропуск на запись для этого документа.
Чтобы изменить несколько блоков, вызовите POST /sync/{doc}/edit со списком того, что вставить, обновить или удалить. Так удобно переключить статус из рабочего процесса или заполнить поле фоновой задачей.
- Изменение попадает в документ.
- За мгновения оно появляется у всех во вкладках.
- Оно записывается в ваши записи, как любая другая правка.
Запрос выполняется целиком: если отклонена хотя бы одна его часть, не применяется ничего. Каждой правке нужен ещё и заголовок Blok-Idempotency-Key длиной от 1 до 128 печатных символов ASCII.
- При журнале операций повтор с тем же ключом и тем же запросом возвращает первый результат и не применяет правку заново.
- Использование того же ключа для другой работы возвращает 409.
- Ответ 204 приходит после надёжной фиксации.
- Он несёт заголовки ответа
Blok-Doc-LineageиBlok-Doc-Sequence.
- Он несёт заголовки ответа
- Если эта фиксация не может завершиться, сервис возвращает 503 и не передаёт изменение дальше.
Сервис, работающий только с рабочей копией, ключ не дедуплицирует и никогда не отвечает 409 на его повторное использование. Повтор ведёт себя там как обычный повтор запроса, а его ответ 204 по-прежнему запускает существующий путь повторов обратной записи. В любом случае ваши собственные записи отстают на задержку обратной записи.
Правку можно и защитить. Отправьте If-Match: "<lineage>:<sequence>", записав оба значения ровно так, как их печатают эти два заголовка ответа. Тогда правка применится, только пока документ всё ещё в этом состоянии.
- Другое состояние даёт 412, и ничего не применяется. Ответ 412 несёт текущие
Blok-Doc-LineageиBlok-Doc-Sequence, так что можно повторить тот же ключ со свежим тегом. - Уже зафиксированный ключ по-прежнему возвращает свой первый результат, даже если его тег устарел.
- У сервиса, работающего только с рабочей копией, нет состояния для проверки, поэтому любой
If-Matchдаёт 428, и ничего не применяется. - Список,
*, слабый тег или любая другая форма дают 400.
Чтобы прочитать документ перед правкой, вызовите GET /sync/{doc}/state. Он возвращает живой документ в JSON, включая правки, сделанные мгновение назад, и для него достаточно пропуска на чтение этого документа. При журнале операций он также отправляет Blok-Doc-Lineage, Blok-Doc-Sequence и ETag: "<lineage>:<sequence>" для того самого состояния, которое отражает. Верните этот ETag в If-Match, и правка применится, только если в промежутке ничего не изменилось.
Готовьтесь к двум отказам. Оба дают 422 и ничего не применяют.
- Попытка поместить блок под родителя, у которого нет списка потомков.
after, которого нет в списке потомков этого родителя.
В ответе названы и шаг, и блок:
collab: op 0: block "p1" has no children list, so nothing can be placed under it.collab: op 0: block "b7" is not in the document order, so nothing can be placed after it.
Вставки применяются одна за другой к документу в его текущем виде. Поэтому две вставки с одним и тем же after окажутся в обратном порядке. Чтобы дописать их по порядку, свяжите их в цепочку, где каждая называет предыдущую.
Чтобы заменить документ целиком, при миграции или исправлении по обращению, сначала сохраните свою версию в собственные записи. Затем вызовите POST /sync/{doc}/reset.
- Сервис выбрасывает рабочую копию.
- Он заново загружает документ из вашего эндпоинта.
- Затем он говорит всем открытым вкладкам отбросить то, что у них было, и взять новую версию.
Никому ничего закрывать не нужно ни в том, ни в другом случае. Редактировать документ, который ни у кого не открыт, тоже можно: сервис загрузит его для этого случая.
Ещё одно закрывает зазор между вашим сохранением и сбросом. Как только ваш эндпоинт ответит версией, каждая обратная запись несёт в заголовке Blok-Doc-Version последнюю, которую он дал. Этот заголовок и есть ваш инструмент оптимистичной блокировки.
Повышайте версию при собственном сохранении и отвечайте на устаревший PUT кодом 409. Сервис считает это неудавшейся обратной записью и повторяет её. Отказ держится, пока сброс не загрузит вашу версию. Так запись, уже ушедшая по проводу в момент вашего сохранения, не ляжет поверх неё.
Доступ, корзина и окончательное удаление принадлежат вашему приложению
Ваше приложение определяет права, корзину, восстановление, сроки хранения и окончательное удаление. Blok даёт операции перепроверки доступа, очистки и локального раздела, но не предоставляет UI настроек доступа.
Для отзыва доступа следующие строки C# являются псевдокодом приложения. Внедрите ICollabRoomManager как rooms, а ICollabDocumentPurger как purger. Сначала зафиксируйте права, затем попросите Blok перепроверить активные подключения.
await host.CommitRevocationAsync(documentId, userId);await rooms.RecheckAccessAsync(documentId);
Вкладка с отозванным доступом закрывается с 4403 forbidden. Разрешённые участники остаются подключёнными и могут писать дальше. Новые подключения тоже должны читать уже зафиксированные права. Вызовите RecheckAccessAsync в каждом экземпляре сервиса, обслуживающем документ. Не используйте reset для изменения прав.
Корзина сохраняет ID документа, содержимое, рабочую копию и журнал на срок хранения вашего приложения. Восстановление меняет только состояние и доступ в приложении. Ни одно действие не вызывает очистку.
Для окончательного удаления следующие строки C# являются псевдокодом приложения. Сначала надёжно сохраните запрет на открытие документа, затем очистите данные сервиса.
await host.CommitPermanentTombstoneAsync(documentId);var result = await purger.PurgeDocumentAsync(documentId, token => host.CanPurgeAsync(operatorUser, documentId, token));
После фиксации запрета авторизация приложения и маршрут документов GET/PUT должны отклонять этот ID даже при наличии журнала. Результат DocumentOpenElsewhere означает, что документ открыт в другом экземпляре; закройте там работу и повторите очистку. Частичный сбой закрывает доступ в этом процессе; сохраните запрет в приложении и повторите очистку. После проверки правил отдельно удалите каноническую запись страницы и указатель в родителе. Никогда не используйте повторно непрозрачный ID страницы.
Без журнала остановите работу с документом в каждом экземпляре сервиса и надёжно запретите GET/PUT у маршрута документов. Блокировка только внутри одного процесса не защитит другой экземпляр или перезапуск. Метка удаления в журнале не даст заново загрузить старое содержимое даже при устаревшем ответе GET.
Для выхода или смены аккаунта следующие строки TypeScript являются псевдокодом приложения. Импортируйте inspectOfflineScope и forgetOfflineScope из @bloklabs/core. Функции showDiscardWarning и closeEditorsAndSwitchScope реализует ваше приложение.
const report = await inspectOfflineScope(oldScope);showDiscardWarning(report);await closeEditorsAndSwitchScope(oldScope);await forgetOfflineScope(oldScope, { discardPending: true });
Проверьте ожидающие отправки правки, пригодные к переносу обновления и карантин до явного удаления всего локального раздела. Сначала закройте или переключите каждую активную вкладку, иначе она создаст раздел снова. Заблокированное или неудачное удаление не является успехом. Уже отключённое от сети устройство нельзя удалить удалённо, пока оно не подключится снова.
Удаление данных сервиса не означает физическое стирание резервных копий, реплик или версий S3. Эти копии покрывает политика хранения вашего приложения.
Ваш маршрут документов сохраняет собственную проверку входа
Сервис обращается к вашему маршруту документов в двух случаях. Он загружает документ, когда его открывают впервые, и записывает изменения обратно, пока люди редактируют.
Если маршрут требует входа, а он должен, положите ожидаемое им значение заголовка в переменную окружения BLOK_DOC_ENDPOINT_AUTH, вместе с Bearer и всем остальным. Сервис отправляет его дословно при каждом вызове. Ваш маршрут, ваша схема авторизации, строить ничего нового не нужно.
Одно правило про само значение: оно должно быть в одну строку. Разрыв строки внутри не даёт сервису запуститься, и он прямо об этом сообщает. Обычная причина, это хвостовой перевод строки от echo или из файла. Заголовок с таким символом просто выпал бы из каждого вызова, и нигде ничего бы не проявилось.
То же значение можно передать флагом --doc-endpoint-auth, но сервис предостерегает от этого: флаг кладёт учётные данные в список процессов машины.
Совсем новый документ должен отвечать «ничего», а не 404
Когда документ открывает первый человек, сервис запрашивает его у вашего маршрута документов. Для документа, который вы ещё ни разу не сохраняли, ответьте 200 с null (или { "data": null }). Это значит «пока ничего», и редактор начнёт с пустого документа.
Ответ 404, 5xx или недоступный маршрут считаются сбоем, а не «новым документом». Редактор показывает уведомление о переподключении, и никто не может начать редактировать.
Пустой документ, посеянный поверх записи, которая просто не загрузилась, позже перезаписал бы её. Это же означает, что опечатка в пути --doc-endpoint громко упадёт при первом открытии, а не тихо опустошит ваши документы.
Rich text приходит на ваш эндпоинт сегментами вместо HTML
Сервис записывает поля rich text на ваш эндпоинт документов сегментами. GET /sync/{doc}/state отвечает так же. Раньше data.text был строкой HTML вроде a <b>b</b>. Теперь это список вроде [{ "text": "a " }, { "text": "b", "marks": { "bold": true } }].
- Ваш эндпоинт должен принимать сегменты, когда сервис записывает документ обратно.
- Отдавать HTML он может и дальше. Сервис по-прежнему принимает HTML от вашего эндпоинта и от POST /sync/{doc}/edit, а обратно пишет сегменты.
- Встроенные поля
textу paragraph, header, quote, toggle и list всегда rich.
Поля rich text ваших собственных инструментов нужно перечислить. На автономном хосте передайте --rich-text-fields в виде JSON, например {"callout":["title"]}, или задайте BLOK_RICH_TEXT_FIELDS. Внутри приложения тот же список называется options.RichTextFields. Перечислите ровно то, что объявляет richTextFields каждого инструмента на клиенте.
Обновляйте сервис и редактор вместе. Документ, сохранённый старым сервисом, переводится в новый формат при первом открытии. Старый редактор отказывается работать с новым сервисом. Новый редактор так же отказывается работать со старым.
Отдавайте загруженные файлы с отдельного хоста, а не с хоста приложения
Загруженный файл отдаётся с того имени хоста, на которое вы его положили, а браузер доверяет странице именно по имени хоста. Поэтому, если загруженные файлы отдаются с того же хоста, что и приложение, чужой файл может вести себя как одна из ваших страниц. Он может прочитать сессию авторизованного пользователя и обратиться к вашему API от его имени.
Сервис отправляет Content-Disposition: attachment и X-Content-Type-Options: nosniff, поэтому браузер скачивает такой файл, а не выполняет его. Но полностью вопрос закрывает только вынос загруженных файлов на отдельный хост: uploads.myapp.com или собственный адрес бакета S3.
Хост, отдающий загруженные файлы, должен отвечать на CORS
Пять вещей в редакторе читают уже сохранённый файл обратно.
- Аудиоплеер скачивает дорожку, чтобы нарисовать её волну.
- Блок изображения скачивает GIF, прежде чем превратить его в видео, и SVG, чтобы узнать его настоящий размер.
- Файловый блок скачивает документ, чтобы показать превью.
- Кнопка скачивания картинки берёт саму картинку, чтобы браузер сохранил её, а не открыл.
Как только загруженные файлы отдаются с отдельного хоста, каждый такой запрос становится кросс-доменным. Браузер отклонит его, если в ответе нет Access-Control-Allow-Origin для вашего приложения.
Все они написаны так, чтобы тихо сдаться, а не сломать блок. Поэтому ошибки вы не увидите.
- Плеер останется без волны.
- GIF останется неподвижной картинкой.
- SVG будет неправильного размера.
- Превью так и не появится.
- Скачивание откроет файл вместо сохранения.
Blok нигде не скажет почему. Разрешите origin вашего приложения на бакете или хосте, с которого отдаются файлы.
Поддержка S3 ещё не встречалась с настоящим бакетом
Загрузка в S3-совместимое хранилище написана и покрыта тестами. Но эти тесты сверяют запрос с нашим собственным прочтением спецификации. На настоящем бакете, в AWS или где-то ещё, она ни разу не запускалась. Прежде чем на неё полагаться, попробуйте на одноразовом бакете.
Если хранилище отклонит запрос, сервис передаст его объяснение как есть. Вы увидите ровно то сообщение, которое прислало само хранилище.
Браузеру, который обращается к сервису напрямую, нужен --allow-origin
Любой запрос с заголовком Origin должен совпасть с одним из адресов, перечисленных в --allow-origin, и так в любом режиме проверки. В режимах none и proxy запрос без Origin, но помеченный Sec-Fetch-Site: cross-site, тоже отклоняется. Обычный серверный запрос с бэкенда, в котором нет ни того, ни другого заголовка, по-прежнему работает.
Режим ticket всегда требует разрешённый Origin. Предварительные (preflight) запросы подчиняются тому же списку.
Загрузка по ссылке принимает только JSON-запросы
POST /upload-by-url требует Content-Type: application/json. Параметры вроде charset=utf-8 допустимы. Другие типы содержимого, включая application/problem+json, получают 415 ещё до того, как сервис пойдёт по ссылке.
За вашим приложением ограничение частоты запросов одно на всех
Сервис ограничивает, как часто вызывающий может заставить его сходить по ссылке.
- Если --rate-limit не указан, режим ticket разрешает 60 запросов в минуту на вызывающего.
- В остальных случаях лимит равен 0, то есть выключен.
- Любое явно заданное значение должно быть нулём или больше.
Вызывающих сервис различает по адресу, с которого пришёл запрос. Когда все запросы пробрасывает ваше приложение, они приходят с одного адреса. Поэтому всё развёртывание делит одно ограничение, и один активный пользователь может выбрать его целиком.
Ограничивать по пользователям должно ваше приложение, на пробрасывающем маршруте, где вы уже знаете, кто авторизован. К третьему пути это не относится: там пропуск называет пользователя, и каждый считается отдельно.
Пропуск называет пользователя, а пропуск для совместного редактирования называет ещё и документ
Пропуск всегда говорит, кто им владеет и можно ли ему писать. Для загрузок и превью ссылок сервис читает только это.
Эти маршруты не ограничивают владельца одним документом. Поэтому пропуск, выданный человеку с одной открытой страницей, работает для любой загрузки и любого превью с этой страницы, пока пропуск жив.
Совместное редактирование строже.
- Пропуск для совместного редактирования называет ещё и документ, который можно открыть, и сервис сверяет это имя на входе.
- Пропуск, который не называет документа или называет другой, разворачивают.
- Он обязан называть и пользователя, потому что лимит подключений и поминутная норма считаются по этому имени.
- Пропуск для совместного редактирования с пустым пользователем отклоняют, а не сваливают в общую кучу с прочими безымянными.
В любом случае держите срок жизни коротким. Выдавайте пропуск только с маршрута, который уже проверил, кто авторизован. Проверку прав на документ для загрузок держите в своём приложении, там вы знаете, какой документ открыт.
Дайте пропуску для совместного редактирования около 30 минут
Пропуск для загрузки проверяется на каждом запросе, поэтому ему хватает нескольких минут жизни. Пропуск для совместного редактирования проверяется один раз, в момент открытия соединения, а соединение затем живёт дольше него. Значит, пропуск должен быть жив в моменты подключения и переподключения редактора.
Около 30 минут дают нужный баланс.
- Это достаточно коротко, чтобы утёкший пропуск быстро устарел.
- И достаточно долго, чтобы проснувшийся ноутбук или моргнувшая сеть не падали из-за пропуска, умершего в промежутке.
Отзыв доступа устроен так же. Он срабатывает при следующем переподключении, а не посреди соединения.
Восемь одновременных подключений на человека к одному документу
В режиме ticket, где браузер приносит пропуск, один человек может держать до восьми открытых подключений совместного редактирования к одному документу. Каждая вкладка считается за одно. Девятое отклоняется с ответом «too many connections» (HTTP 429), пока одно из них не закроется.
Пропуск для совместного редактирования обязан называть пользователя. Пропуск, который никого не называет, разворачивают на входе с ответом «pass names no user» (код закрытия 4401).
За прокси все такие пропуска делили бы один лимит и одно окно частоты. Если сервис работает внутри вашего приложения ASP.NET и ваша проверка входа назвала пользователя, считается именно он.
За вашим приложением или прокси, то есть в режимах none и proxy отдельно запущенного сервиса, лимита нет вовсе. Там никто не называет человека. Все подключения приходят с одного адреса прокси, а общий лимит душил бы всех разом.
Если ограничение там нужно, поставьте его в прокси, где вы уже знаете, кто авторизован.
1024 одновременных подключения на процесс, на всех вместе
Лимит выше считает одного человека. Этот потолок считает всех вместе и действует в любом режиме авторизации.
С включённым --collab отдельно запущенный сервис держит не больше 1024 открытых подключений совместного редактирования на процесс. Поэтому поток подключений, которые открыли и просто держат открытыми, не займёт все подключения, какие процесс вообще способен держать. Сервис ведёт этот счёт сам.
Сверх потолка новое подключение отклоняется с ответом «too many connections on this server» (HTTP 503), пока одно из открытых не закроется. Отказ приходит ещё до загрузки документа, поэтому ради подключения, которое всё равно не заработает, у вашего маршрута ничего не запрашивается.
Внутри вашего приложения ASP.NET потолка нет, пока вы не зададите CollabMaxConnections, где 0 означает «без потолка». Ставьте туда то же число, что и в MaxConcurrentUpgradedConnections у Kestrel. Kestrel остаётся страховкой снизу и отказывает позже и менее внятно: он сначала даёт документу загрузиться, а потом отвечает 500.
Чтобы масштабироваться, разделите документы между процессами
Пока документ открыт, его рабочую копию держит в памяти ровно один процесс сервиса. Каждое подключение к этому документу должно попадать именно в него.
Два процесса, открывшие один и тот же документ, вели бы каждый свою копию и по очереди затирали бы в хранилище работу друг друга. Поэтому лишние процессы помогают только тогда, когда каждый документ принадлежит одному из них.
Разделить их просто, потому что идентификатор документа стоит прямо в адресе подключения. Маршрутизируйте по идентификатору документа, чтобы каждый запрос с путём /sync/{doc} попадал в один и тот же процесс. Подойдёт любое детерминированное правило, от «идентификаторы с a по m идут в первый процесс» до хеша идентификатора.
- Вызов сброса живёт под тем же путём, поэтому он автоматически приходит в процесс, которому принадлежит документ.
- Каждый процесс несёт свою долю документов со своим потолком подключений.
- Все они могут делить одну папку или бакет хранилища и один эндпоинт документов, потому что никакие два из них никогда не открывают один документ.
- Загрузки и превью ссылок ничего не держат в памяти, поэтому такие запросы могут идти в любой процесс.
Встроенной координации за этим нет. Процессы не знают друг о друге, и ничто в сервисе не помешает ошибке маршрутизации открыть один документ дважды. Правило маршрутизации и есть гарантия, и держать его должны вы.
Чего сервис за вас не ограничивает
Сервис не ограничивает, сколько документов один человек держит открытыми. Он не ограничивает, до какого размера может вырасти документ. Он не ограничивает и то, как часто неизвестный id документа заставляет сервис запрашивать исходную версию у вашего маршрута документов.
Это ваши записи и ваши правила. Если такие ограничения нужны, поставьте их в своём бэкенде, там же, где уже живёт проверка прав.
Сервис ограничивает только само соединение, и делает это пятью способами.
- Одно сообщение не больше 1 МиБ. Сообщение крупнее закрывает соединение.
- Браузер может перестать принимать сообщения, например при зависшей вкладке или пропавшей сети.
- Он закрывается, как только ожидающие его сообщения превысят 8 МиБ.
- При переподключении он догоняет остальных.
- Внутри вашего приложения ASP.NET оба числа вместе сдвигает CollabMaxMessageBytes.
- Соединение, которое заваливает сервис, закрывается с ответом «inbound rate exceeded» (код 1008), и редактор переподключается.
- Заваливает означает больше примерно 50 сообщений в секунду после того, как всплеск в 100 исчерпан.
- Ещё это значит чаще 60 раз в минуту просить прислать весь документ заново или просить всех присутствующих объявиться заново.
- И это значит дробить одно сообщение на нелепое число кусков. Норма тут шестнадцать кусков плюс ещё один на каждые 64 байта сообщения. Поэтому поток пустых кусков обрывается на семнадцатом.
- Присутствие считается ещё и в байтах, примерно 128 КиБ в секунду на соединение после всплеска примерно в 512 КиБ.
- За поток присутствия соединение закрывают точно так же.
- Внутри вашего приложения ASP.NET эти числа задают CollabInboundFramesPerSecond, CollabInboundBurstFrames, CollabInboundResyncsPerMinute и CollabInboundAwarenessBytesPerSecond.
- Значение 0 отключает любую из этих скоростей.
- Открытие соединения тратит тот же поминутный бюджет --rate-limit, что и любой другой маршрут.
Кроме этих пяти границ открытое соединение ничем не ограничено. Предел размера сообщения сервис сообщает редактору при подключении. Поэтому редактор сам отказывается отправить слишком большое, а не оказывается за это отключён.
Одно правило про форму, а не про размер. Провод синхронизации двоичный, поэтому текстовое сообщение закрывает соединение с ответом «binary frames only» (код 1003). Редактор Blok таких не шлёт, значит это закрытие означает, что с маршрутом говорит что-то другое.
Чего сервис за вас не проверяет
Сервис проверяет форму того, что присылает пропуск на запись, но не смысл. Из-за этого насквозь проходят три вещи, и о них стоит знать.
- Одинокий суррогат превращается на проводе в U+FFFD. Это половина пары UTF-16, которую при наборе не получить, а скриптом можно. Дальше символ замены читает каждая реплика, включая собственную копию того же браузера после перезагрузки.
- Обновление может ничего не интегрировать, потому что изменения, от которых оно зависит, так и не пришли. Такое обновление не отклоняется. Оно ретранслируется и сохраняется, как любое другое.
- Снаружи повторная доставка и законно пришедшее не по порядку изменение выглядят одинаково. Поэтому тот, кто шлёт их без конца, растит рабочую копию, пока документ не будет выгружен из памяти, и больше ничто это не ограничивает.
- Всё, что приходит через документ, другой браузер или запрос edit, может поместить блок в контейнер, чей childTools его запрещает. Редактор ставит блок туда, куда говорит документ, и пишет в лог предупреждение с именами потомка и контейнера. Он его не понижает, потому что понижение записалось бы обратно и зациклилось бы с другим участником.
Единственное, что насквозь не проходит, это блок, в котором читающая сторона не может разобраться. Блок, чей id или type не текст, в сохранённый документ не попадает. Не попадает и блок, чей data не набор полей. В лог по каждому уходит предупреждение с его именем.
Значение, вложенное глубже 256 уровней, сохраняется как null. Редактор и сервис поступают здесь совершенно одинаково. Поэтому то, что сохраняет браузер, и то, что сервис записывает в ваши записи, совпадает блок в блок.
Только чтение до первой синхронизации и редактирование при оборванном соединении
Документ открывается только для чтения и остаётся таким, пока не придёт первая синхронизация. Редактор никогда не начинает со своей собственной копии. Два браузера, каждый со своей копией, после синхронизации удвоили бы каждый блок.
Как только первая синхронизация пришла, документ становится редактируемым. Это верно, пока пропуск даёт право на запись и пока ваше приложение само не поставило редактор в режим чтения.
Дальше поведение намеренно несимметрично. Если соединение оборвалось, документ остаётся редактируемым. Набранное копится в этой вкладке. Оно уходит, как только вкладка переподключится. Так туннель или уснувший ноутбук не замораживают страницу под руками.
О каждом шаге редактор сообщает событием collaboration:status. Шаги такие: connecting, connected, offline, пока он продолжает переподключаться, и error. error значит, что он остановился насовсем и сам уже не переподключится.
Событие несёт список присутствующих, поэтому индикатор вы рисуете свой, в том виде, который уже принят в вашем продукте. Blok его за вас не рисует.
Ни одно из этих четырёх состояний не говорит, что работа сохранена. Они описывают сокет, а не очередь за ним. Что именно принято, говорит следующая запись.
Подключено не значит сохранено: редактор сообщает об этих двух вещах отдельно
Connecting, connected и offline описывают сокет. Ни одно из них не говорит, что работу приняли. То же событие collaboration:status несёт второй блок, который об этом говорит: save.
Его state принимает одно из пяти значений.
- saved значит, что ничего не ждёт.
- pending значит, что правки стоят в очереди в этой вкладке и ещё не подтверждены.
- Подключённая сессия тоже сообщает pending, потому что поднятый сокет ещё не означает сохранение.
- blocked значит, что локальная очередь сломалась и отправлять больше ничего нельзя, с причиной local-storage-failed.
- Редактирование останавливается до конца сессии.
- quarantined значит, что правки вынули из очереди и уже никогда не отправят, а quarantinedOperations их считает.
- При включённой опции офлайна они остаются в копии этого браузера.
- Без неё остаётся только этот счётчик.
- unavailable значит, что о надёжном сохранении сказать нечего вовсе.
- Так отвечает сервис без журнала операций, с причиной legacy-protocol, как только он об этом сообщил.
Рядом с состоянием pendingOperations и pendingBytes показывают размер очереди. serverSequence говорит, куда журнал положил последнюю подтверждённую правку. Это десятичная строка, потому что потолок не помещается в число JavaScript.
- Рисуйте connected вместе с pending как «сохраняю», а saved как сохранено.
- Рисуйте quarantined как ошибку, о которой человеку надо сказать.
- Рисуйте unavailable как отсутствие индикатора сохранения, а не как зелёную галочку.
Событие несёт это независимо от того, менялось ли соединение. Состояние сохранения, изменившееся само по себе, публикует событие. Одинаковая нагрузка не публикует ничего. Вскоре после готовности редактора приходит один повтор, поэтому поздно подписавшийся слушатель тоже узнаёт, как обстоят дела.
Одна оговорка про счётчики: локальная очередь, которую вообще не удаётся прочитать, показывает ноль строк. Поэтому ноль при blocked означает «неизвестно», а не «ничего не ждёт».
Граница точная. Правка считается сохранённой, когда сервис зафиксировал её в вашем журнале операций. Эта запись должна вернуться раньше, чем правку покажут кому-то ещё.
- Отправка в сокет это не она.
- Увиденное соседом изменение это не она.
- И обратная запись целикового JSON в ваш маршрут тоже не она: она случается позже и может отвалиться сама по себе.
Поэтому же этот путь нужен и одному человеку. Одиночный редактор, направленный на сервис, получает ту же очередь, тот же журнал и ту же квитанцию, что и десять человек. Индикатор и здесь надо читать из save.
Без журнала операций всё это недоступно. Ни один флаг его не включает, поэтому обычный сервис договаривается о старом протоколе и всю сессию сообщает unavailable.
Перезагрузка страницы без соединения теряет набранное офлайн
По умолчанию правки, сделанные офлайн, живут только в этой вкладке и нигде больше, пока она не переподключится. Перезагрузите её, закройте или потеряйте браузер до этого момента, и они пропадут. Вкладка держала единственную копию, а до рабочей копии и до ваших записей ничего ещё не дошло.
Включённая в редакторе опция офлайна это меняет. Она хранит копию документа в этом браузере. После перезагрузки работа подхватывается и уходит при следующем подключении.
По умолчанию опция выключена. Она записывает содержимое документа в хранилище браузера посетителя. Это решение о ваших данных, а не о редакторе.
Про копию важно знать две вещи.
- До первой удачной синхронизации не сохраняется ничего. Поэтому редактор, который так и не достучался до сервиса, всё равно не может начать со своей собственной копии.
- Копия выбрасывается, как только сервис сообщает, что его история больше не совпадает, например после сброса. Так устаревшую копию невозможно принять за живой документ.
Для включения нужно ещё одно: offlineScope, устойчивый идентификатор того, кто вошёл в систему. Хранилище принадлежит браузеру, а не человеку. Именно этот идентификатор не даёт копии одного человека попасть к другому.
Редактор откажется запускаться, если опция офлайна включена, а offlineScope не задан. Так копии без владельца просто не появится.
Сделайте этот идентификатор непрозрачным и устойчивым: одна и та же строка при каждом входе этого человека и на каждом устройстве. Всё, что меняется, например пропуск или идентификатор сессии, при каждом обновлении страницы заводит новый раздел. Он оставляет позади все прежние копии вместе с офлайновыми правками.
И сама копия не резервная копия. Blok никогда не просит браузер её беречь, поэтому браузер, которому не хватает места, может выбросить её никого не спросив. Очистка данных сайта делает то же самое намеренно.
Если вы не включаете опцию офлайна, совет остаётся прежним. Пока редактор пишет, что он офлайн, оставьте вкладку в покое и дайте ей переподключиться.
Двое в одном поле: побеждает тот, кто записал последним
Правки в разных блоках сливаются чисто, и правки в разных полях одного блока тоже. Один человек переименовывает заголовок, другой отмечает пункт списка задач: они не сталкиваются, и ничего не теряется.
Внутри одного поля всё грубее. Двое, печатающих в одном абзаце одновременно, не получат переплетения букв. Остаётся то изменение, которое записалось последним, второе пропадает.
Notion ведёт себя так же, как и любой редактор, который синхронизирует поля целиком, а не по символам. Слияние по символам будет в следующей фазе. На практике двое редко печатают в одном предложении сразу, а присутствие как раз и нужно, чтобы это было видно заранее.
Кто здесь и где именно
Присутствие Blok рисует сам.
- Вы получаете маленькие аватары людей в документе.
- Вы получаете цветной курсор там, где каждый из них печатает.
- Маленький кружок с инициалами на полях рядом с блоком, в котором человек находится.
Когда человек перестаёт двигаться, курсор начинает пульсировать. Наведите на кружок, чтобы увидеть полное имя. На самом курсоре имени нет, поэтому документ с четырьмя людьми не превращается в стену из имён.
И то и другое берётся из отображаемой личности, которую вы передаёте в опции collaboration редактора. Это имя и необязательный цвет для аватара, кружка и курсора этого человека.
Если не передать, человек всё равно появится: он получит космический силуэт (комету, ракету, спутник). Силуэт идёт с подписью на языке читателя. Так никто не остаётся пустым кружком, и двое в одном документе никогда не носят один силуэт.
Это только оформление. Оно не связано с отдельной опцией user, которая проставляет, кто последним правил блок.
Одна отвечает на вопрос «чей это курсор», другая на вопрос «кому засчитать эту правку». Задавайте любую, обе или ни одной.
Когда кто-то выделяет текст внутри одного блока, этот фрагмент тоже подсвечивается его цветом. Это полупрозрачная заливка поверх текста, а не под ним. Клики она не перехватывает, так что вы по-прежнему можете поставить свой курсор в любом месте внутри неё. Выделение, растянутое на несколько блоков, не рисуется вовсе. Курсора в этом случае тоже не будет.
Присутствие не проверяется
Маленькие аватары и курсоры, которые показывают, кто сейчас в документе, передаются ровно так, как их отправил каждый браузер. Сервис не читает, что в них написано, как и любой сервер совместного редактирования на том же протоколе синхронизации.
А вот форму он проверяет. Сообщение о присутствии, которое браузеры-получатели не смогут разобрать, обрывает им сеанс, поэтому каждое обходится целиком, и состояние каждого человека обязано быть корректным JSON.
- Негодное состояние отбрасывается и попадает в журнал.
- Третье негодное с одного соединения закрывает его с ответом «malformed awareness» (код 1008), и редактор переподключается как обычно.
Чего не проверяют никогда, так это что именно присутствие утверждает. Любой участник, в том числе с пропуском только на чтение, может объявить любое имя и любую позицию. Пропуск защищает содержимое документа: кто может открыть документ и кто может его менять, проверяется на входе.
Присутствие это знак вежливости для людей в комнате, а не проверка личности. Никогда не стройте на нём права доступа или журнал действий.
Присутствие охватывает до 256 человек в одном документе
Документ передаёт присутствие не более чем для 256 человек одновременно. К присутствию относятся аватары и курсор, который показывает, где сейчас печатает каждый.
Сверх этого обновления присутствия отбрасываются, а не передаются дальше, и сервис пишет об этом строку в свой журнал. Это просто отбрасывание: людная комната никому не стоит соединения, в отличие от сообщения о присутствии, которое сервис вообще не может разобрать.
Редактирования это не задевает. Все продолжают печатать, и каждое изменение по-прежнему сливается и доходит до ваших записей как обычно. То есть если документ когда-нибудь станет настолько людным, сломается видимость людей друг для друга, а не их работа.
Это ухудшение только присутствия, а не предел на число тех, кто может править один документ. Настройки для этого числа нет. Оно зафиксировано внутри сервиса.
Id документа занимает один сегмент пути
Идентификатор документа едет в адресе соединения (/sync/{doc}) и внутри пропуска, и они должны совпадать символ в символ. Отсюда главное правило: идентификатор должен быть одним сегментом пути.
Буквы, цифры, дефисы, подчёркивания и точки безопасны. Слэш, даже закодированный, отклоняется на входе с понятным сообщением «document ids must be a single path segment». Он не падает позже как пропуск, который загадочным образом называет другой документ.
Используйте собственные непрозрачные id. Если ваши записи адресуются путями, сопоставьте каждому пути токен на своей стороне и отдавайте редактору токен.
Терминируйте TLS до того, как трафик из интернета дойдёт до сервиса
Отдельный хост говорит по обычному HTTP. Он не терминирует TLS и не занимается сертификатами. Развёртывание, смотрящее в интернет, должно стоять за обратным прокси или хостинг-платформой, которая принимает HTTPS и передаёт HTTP по внутреннему или loopback-соединению. Никогда не передавайте пропуск по открытому интернет-трафику.
У вас уже есть хранилище
Вы используете Supabase, S3, Cloudinary или что-то похожее и хотите, чтобы файлы загружались сразу туда.
Запускать не нужно ничего. Браузер загружает файлы в хранилище, за которое вы и так платите, через готовый загрузчик из @bloklabs/presets. Это самый дешёвый путь, и большинству стоит выбрать именно его.
Двух вещей он не умеет: превью ссылок и совместного редактирования.
- Превью означает чтение чужой страницы, а браузеру это не разрешено.
- Совместное редактирование означает перенос правок между браузерами людей, и для него нужно что-то работающее, куда могут дотянуться оба.
Если нужно то или другое, оставьте этот загрузчик и добавьте сервис ради этой возможности. Одно другому не мешает.
Настройка редактора
Направьте редактор на ваше хранилище
import { Blok } from '@bloklabs/core';
import { supabaseStorage } from '@bloklabs/presets';
new Blok({
holder: 'editor',
uploader: supabaseStorage(supabase, { bucket: 'blok' }),
// Nothing here turns on live editing: it needs the service in the middle,
// exactly like link previews do. The three paths below run it — take one
// and keep this uploader as it is.
});Когда что-то ломается
| Что вы видите | Почему | Что делать |
|---|---|---|
| Вставленная ссылка остаётся обычной ссылкой и не превращается в карточку превью. | Страницу за этой ссылкой никто не читает. Браузер не может сходить на чужой сайт и прочитать его заголовок и картинку. Сайт должен это разрешить, а почти никто не разрешает. | Запустите сервис только ради превью или направьте инструмент «Закладка» на сторонний сервис превью. Если превью вам не нужны вовсе, то всё уже настроено. |
| При вставке ссылки на картинку с другого сайта сохраняется сама ссылка, а не копия картинки в вашем хранилище. | Чтобы скопировать удалённый файл, запрос должен сделать сервер. Загрузка через Supabase и по предподписанным ссылкам происходит целиком в браузере, поэтому сделать такой запрос некому. | Пресет Cloudinary умеет это сам, и сервис тоже. В остальных случаях исходная ссылка сохраняется как есть. Она по-прежнему отображается, но зависит от того, останется ли доступен чужой сайт. |
| Два человека открыли один документ, и ни один не видит правок другого. | Правки между их браузерами никто не переносит. Совместному редактированию нужен сервис посередине, по той же причине, что и превью ссылок: браузер сам этого не может. | Запустите сервис с флагом --collab и укажите ему, где ваше приложение сохраняет документы. Этот загрузчик продолжит работать рядом без изменений. |
Ваше приложение на ASP.NET Core
Вы хотите, чтобы маршруты Blok жили внутри .NET-приложения, которому уже принадлежат ваши пользователи и развёртывание.
Установите пакет для ASP.NET Core и смонтируйте общие обработчики на C# в уже работающем процессе. Никакого второго хоста, порта или контейнера обслуживать не нужно. Загрузку файлов и превью ссылок защищает политика авторизации вашего приложения.
Совместное редактирование работает в том же процессе. Разрешите WebSockets, скажите Blok, где лежат ваши документы, и добавьте проверку, которая решает, кому какой документ можно открыть. Редактор обращается к тому префиксу маршрутов, который выберете вы.
Блоки баз данных и интеграция с MySQL появятся позже.
Пакет также конвертирует сохранённые документы прямо в процессе: в Markdown, HTML или обычный текст и обратно из Markdown. Он запускает собственную реализацию Blok, а не её порт на C#. Порт молча теряет каждый блок, о котором ему не рассказали.
Что запустить
Установите пакет для ASP.NET Core
dotnet add package Blok.Server.AspNetCoreОдин маршрут в вашем приложении
Зарегистрируйте и смонтируйте Blok в приложении
using Blok.Server.AspNetCore;
builder.Services.AddBlokServer(options =>
{
options.StorageDirectory = "./blok-uploads";
options.PublicUrl = "https://uploads.example.com/files";
options.UnfurlDisabled = false;
// Live collaboration. The service keeps a working copy of open documents
// and loads and saves the record through your own document routes.
options.CollabEnabled = true;
options.DocEndpoint = "https://myapp.com/api/documents";
});
var app = builder.Build();
// Without this call the service cannot open the long-lived connection
// collaboration needs, and refuses it with a message saying so.
app.UseWebSockets();
app.MapBlokServer("/api/blok").RequireAuthorization();Решите, кому какой документ можно открывать
using System.Security.Claims;
using Blok.Server.AspNetCore;
// Asked when someone opens a document for live collaboration, after your
// RequireAuthorization policy has already run. Without it, any signed-in
// user may open any document.
public sealed class DocumentRules : IBlokAuthorization
{
public ValueTask<bool> CanReadDocumentAsync(
ClaimsPrincipal user, string documentId, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(true /* your own check */);
public ValueTask<bool> CanWriteDocumentAsync(
ClaimsPrincipal user, string documentId, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(true /* your own check */);
}
// Chain it onto the registration above:
builder.Services.AddBlokServer(options => { /* as above */ })
.UseAuthorization<DocumentRules>();Конвертируйте документы — без хранилища и маршрута
using Blok.Server.Documents;
// Registers the converter on its own. AddBlokServer includes it, but a service
// that only reads documents does not have to configure a bucket to get one.
// Its engine pool is built at startup, so no reader waits a second for it;
// pass warmUp: false in a test host that starts often and converts rarely.
builder.Services.AddBlokDocuments();
// Anywhere you already have the saved JSON — an MCP tool, an agent, an export.
public sealed class ArticleExport(IBlokDocumentConverter blok)
{
public async Task<string> ToMarkdownAsync(string contentJson, CancellationToken ct)
{
var conversion = await blok.ToMarkdownAsync(contentJson, ct);
foreach (var warning in conversion.Warnings)
{
// e.g. construct "callout", action "degraded", detail "rendered as a blockquote…"
logger.LogInformation("{Construct} {Action}: {Detail}", warning.Construct, warning.Action, warning.Detail);
}
return conversion.Markdown;
}
}Переведите документ, не отдавая модели его JSON
// A model handed a document's JSON breaks the structure — dropped ids,
// reordered blocks, invented fields. Hand it a flat list of strings instead.
var texts = await blok.ExtractTextsAsync(documentJson, cancellationToken: ct);
var translated = await TranslateAsync(texts, ct);
var document = await blok.InjectTextsAsync(documentJson, translated, cancellationToken: ct);
// The list is in document order, holds no URLs and no file names, and skips
// empty values. A count that does not match the document throws rather than
// misplacing a translation.
// The version a saved document carries is whatever wrote it, so ask for the
// one the editor stamps rather than inventing a number.
var version = await blok.GetVersionAsync(ct);
// The saved format as JSON Schema, for constraining a model's structured
// output or validating an import.
var schema = await blok.GetSchemaAsync(ct);Проиндексируйте документ и обработайте сбой преобразования
// The default text is what a reader sees on screen. A search index wants
// more: the alt text of an image, the artist of a track, the URL somebody
// pasted as a bare link. That is what includeHiddenText adds.
var text = await blok.ToPlainTextAsync(contentJson, includeHiddenText: true, cancellationToken: ct);
// Conversion runs Blok's own JavaScript, so it is bounded. Every failure
// arrives as one type carrying a reason — you never catch the engine's.
try
{
var markdown = await blok.ToMarkdownAsync(contentJson, ct);
}
catch (BlokDocumentConversionException failure)
{
var advice = failure.Reason switch
{
BlokConversionFailure.DocumentTooLarge => "Split the document and try again.",
BlokConversionFailure.TimedOut => "Split the document and try again.",
BlokConversionFailure.InvalidDocument => "This is not a saved Blok document.",
_ => "Retry, then report it.",
};
logger.LogWarning("Conversion failed ({Reason}): {Advice}", failure.Reason, advice);
}
// A conversion is bounded by a timeout and by how much the interpreter may
// allocate for one call. The budget is allocation churn, NOT resident memory,
// and nothing is reserved — raise it for documents with large inline images.
builder.Services.AddBlokDocuments(allocationBudgetBytes: 1024L * 1024 * 1024);Настройка редактора
Направьте редактор на смонтированные маршруты
import { Blok } from '@bloklabs/core';
new Blok({
holder: 'editor',
// The prefix you mapped above. Fills in the uploader and the link-preview
// endpoint; anything you set yourself wins.
server: '/api/blok',
// Live collaboration. Everyone who opens the same doc edits together, and
// the name and color are what the other people see.
collaboration: {
doc: 'article-42',
user: { name: 'Jack', color: '#f60' },
},
});Когда что-то ломается
| Что вы видите | Почему | Что делать |
|---|---|---|
| Маршруты загрузки отвечают 404, хотя проверка состояния и превью ссылок работают. | Хранилище выключено, поэтому пакет не монтирует маршруты, которые всё равно не смогли бы отработать. | Задайте StorageDirectory или настройте параметры S3 при вызове AddBlokServer. |
| Приложение отвергает конфигурацию Blok ещё до того, как смонтирует маршруты или откроет хранилище. | MaxUploadBytes не может быть больше Array.MaxLength, когда одновременно включены хранилище и получение превью с чужих сайтов. RateLimitPerMinute должен быть нулём или больше.
| Укажите безопасный публичный URL, оставьте удалённые S3-эндпоинты на HTTPS, при необходимости снизьте лимит на буферизуемую загрузку и слушайте IP-адрес, localhost или явный wildcard. |
| Открытие документа для совместного редактирования сразу же падает с сообщением про WebSockets. | Приложение не вызвало UseWebSockets(), поэтому долгоживущее соединение, нужное совместному редактированию, открыть невозможно. Сервис отказывает и называет в сообщении недостающий вызов. Редактор не висит до таймаута. | Добавьте app.UseWebSockets(); перед MapBlokServer, как в примере выше. |
| В журнале при запуске появляется предупреждение: маршруты совместного редактирования открыты любому, кто может достучаться до приложения. | Совместное редактирование включено, пропуска сервис не использует, и проверка документов не зарегистрирована. Поэтому никто не решает, кому какой документ можно открыть. В предупреждении названы все три затронутых маршрута: соединение синхронизации, его reset и его edit. | Зарегистрируйте проверку документов из примера выше или переведите сервис на пропуска. Предупреждение пишется один раз, при регистрации, и вашу политику оно не видит. Поэтому группа, на которой уже висит RequireAuthorization(), получит его тоже. Об этом сказано и в самом тексте предупреждения. |
У вас свой бэкенд
У вас есть приложение на Django, Rails, Laravel, Node или Go, работающее на машине, которой вы управляете.
Запустите сервис рядом с приложением так, чтобы он слушал только 127.0.0.1. Этот адрес доступен с вашей же машины и больше ниоткуда, поэтому из интернета к сервису не обратиться.
В приложении появляется один новый маршрут, который пробрасывает запрос дальше. Ваша существующая проверка входа защищает этот маршрут так же, как всё остальное. Никаких вторых пользователей, второго пароля, домена или сертификата заводить не нужно.
Совместное редактирование идёт через тот же маршрут. Дополнительно ваш прокси должен уметь только одно: пропускать его долгоживущее соединение. Пример ниже.
Что запустить
Запустите сервис рядом с приложением
docker run \
--network host \
--mount type=volume,source=blok-server-data,target=/data \
ghcr.io/jackuait/blok-server \
--listen 127.0.0.1:4000 \
--auth proxy \
--allow-origin https://myapp.com \
--storage-dir /data \
--public-url https://uploads.myapp.com/filesОдин маршрут в вашем приложении
Добавьте в приложение один пробрасывающий маршрут
import { createProxyMiddleware } from 'http-proxy-middleware';
// Everything under /api/blok goes to the service. requireLogin is your own
// existing check — the service trusts whatever reaches it, so this line is
// what decides who may upload.
app.use(
'/api/blok',
requireLogin,
createProxyMiddleware({
target: 'http://127.0.0.1:4000',
pathRewrite: { '^/api/blok': '' },
// Live collaboration holds a connection open through this route.
// Without ws: true uploads work and collaboration never connects.
ws: true,
}),
);Настройка редактора
Направьте редактор на ваш собственный маршрут
import { Blok } from '@bloklabs/core';
new Blok({
holder: 'editor',
// Fills in the uploader and the link-preview endpoint. Anything you set
// yourself wins, so your own storage still works alongside it.
server: '/api/blok',
// Live collaboration. Everyone who opens the same doc edits together, and
// the name and color are what the other people see.
collaboration: {
doc: 'article-42',
user: { name: 'Jack', color: '#f60' },
},
});Когда что-то ломается
| Что вы видите | Почему | Что делать |
|---|---|---|
| Контейнер печатает «blok-server refused to start» и сразу завершается. | Ему велели слушать адрес, отличный от 127.0.0.1, в режиме, который ничего не проверяет. Отказ сделан намеренно: запустившись, сервис оказался бы в интернете без всякой проверки. | Оставьте --listen на 127.0.0.1. Если сервис действительно должен быть доступен снаружи, это третий путь ниже, и там используется --auth ticket. |
| Загрузка файлов отвечает 404, а превью ссылок работают. | В этом развёртывании не настроено хранилище, поэтому маршруты загрузки вообще не зарегистрированы. | Укажите сервису через --storage-dir, куда класть файлы, или направьте его в S3-совместимый бакет. |
| Файлы загружаются, но картинки битые у всех, кроме вас. | --public-url задаёт адрес, с которого браузер загружает сохранённые файлы. По умолчанию это loopback-адрес, который слушает сервис. Такой адрес на любой машине означает «эта машина», поэтому он работает у вас и больше ни у кого. | Задайте в --public-url настоящий адрес и направьте это имя хоста на сервис или на веб-сервер, отдающий ту же папку. Возьмите хост, отличный от хоста приложения. Почему, написано в разделе выше. |
| Большие файлы не загружаются: они не доходят до сервиса. | На этом пути каждый загружаемый файл идёт через ваше приложение, поэтому первым срабатывает его собственное ограничение на размер запроса. | Поднимите лимит на размер тела запроса для пробрасывающего маршрута или выберите путь с собственным хранилищем выше, где файлы вообще не проходят через приложение. |
| Из-за одного активного пользователя все остальные упираются в «rate limit exceeded». | Все запросы приходят в сервис от вашего приложения. С его точки зрения это один и тот же вызывающий, и лимит у них общий. | Ограничивайте по пользователям в своём приложении, на пробрасывающем маршруте: там вы уже знаете, кто авторизован. |
| В разработке вы направляете редактор прямо на http://127.0.0.1:4000, и браузер блокирует запрос. | Сервис отвечает браузеру, только если тот пришёл с адреса, который сервису заранее назвали, а по умолчанию не назвали ни одного. | Ходите через собственный маршрут приложения, как показано выше, или передайте --allow-origin с адресом вашего дев-сервера. |
Вы на Vercel или Netlify
Ваше приложение развёрнуто на хостинге, который запускает код по требованию. Рядом с ним ничего не работает постоянно.
Машины, рядом с которой можно поставить сервис, здесь нет, поэтому он разворачивается отдельно (Fly, Railway, Render, небольшой VPS) на имени хоста, которое принадлежит вам. Теперь он доступен из интернета, поэтому проверяет каждый запрос. Хостинг или обратный прокси должны терминировать TLS и передавать сервису уже обычный HTTP.
Ваше приложение добавляет один маршрут, который выдаёт браузеру короткоживущий пропуск с именем авторизованного пользователя. Сервис проверяет пропуск сам, не обращаясь обратно к приложению. Затем он либо пропускает запрос, либо отказывает.
Совместное редактирование работает на том же развёртывании. Сервис держит рабочую копию документов, которые сейчас редактируют. Загружает и сохраняет запись он через собственные маршруты документов вашего приложения.
Что запустить
Разверните сервис на своём имени хоста
docker run \
-p 127.0.0.1:4000:4000 \
--mount type=volume,source=blok-server-data,target=/data \
-e BLOK_SECRET \
ghcr.io/jackuait/blok-server \
--listen 0.0.0.0:4000 \
--auth ticket \
--allow-origin https://myapp.com \
--storage-dir /data \
--public-url https://blok.myapp.com/filesТот же сервис, но с совместным редактированием
docker run \
-p 127.0.0.1:4000:4000 \
--mount type=volume,source=blok-server-data,target=/data \
--mount type=volume,source=blok-collab,target=/collab \
-e BLOK_SECRET \
-e BLOK_DOC_ENDPOINT_AUTH \
ghcr.io/jackuait/blok-server \
--listen 0.0.0.0:4000 \
--auth ticket \
--allow-origin https://myapp.com \
--storage-dir /data \
--public-url https://blok.myapp.com/files \
--collab \
--collab-dir /collab \
--doc-endpoint https://myapp.com/api/documentsОдин маршрут в вашем приложении
Добавьте один маршрут, который выдаёт пропуск
// app/api/blok-ticket/route.ts
import { blokTicket } from '@bloklabs/server/ticket';
export async function GET(request: Request) {
const session = await getSession();
if (!session) {
return new Response('Not signed in', { status: 401 });
}
// With live collaboration on, the editor asks for a pass naming the
// document it is opening (?doc=…). This is where your own permission
// check runs: refuse here and that person never gets in. A collaboration
// pass is checked once, when the connection opens, so it gets a longer
// life than the five-minute upload default.
const doc = new URL(request.url).searchParams.get('doc');
return Response.json({
ticket: blokTicket(process.env.BLOK_SECRET, {
user: session.userId,
write: true,
...(doc === null ? {} : { doc, ttlSeconds: 30 * 60 }),
}),
});
}Тот же маршрут, если ваш бэкенд не на JavaScript
// A pass is a standard HS256 JWT and nothing more. Every language has a
// one-line library for it; this is spelled out so you can see there is no
// magic in it, and so you can port it wherever your backend lives.
//
// header {"alg":"HS256","typ":"JWT"} these two keys, in this order
// claims user your own user id, stored but never interpreted
// doc for live collaboration, the one document this pass
// may open; upload and preview routes ignore it
// write false may read previews; uploads need true
// exp seconds since the epoch; keep the life short
// secret the same BLOK_SECRET the service runs with, 32 chars or more
//
// Both segments are base64url with the padding stripped, joined with a dot,
// and the signature is HMAC-SHA256 over that string, base64url again.
import { createHmac } from 'node:crypto';
const b64 = (value: string) => Buffer.from(value).toString('base64url');
const head = b64(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = b64(
JSON.stringify({
user: session.userId,
write: true,
exp: Math.floor(Date.now() / 1000) + 60 * 60,
}),
);
const signature = createHmac('sha256', process.env.BLOK_SECRET ?? '')
.update(`${head}.${body}`)
.digest('base64url');
const ticket = `${head}.${body}.${signature}`;Настройка редактора
Направьте редактор на сервис и передайте пропуск
import { Blok } from '@bloklabs/core';
new Blok({
holder: 'editor',
server: 'https://blok.myapp.com',
// The editor fetches a pass from this route, keeps it, and replaces it
// shortly before it expires. Uploads and link previews share one; live
// collaboration asks the same route for its own, naming the document.
ticket: '/api/blok-ticket',
// Live collaboration. Everyone who opens the same doc edits together, and
// the name and color are what the other people see.
collaboration: {
doc: 'article-42',
user: { name: 'Jack', color: '#f60' },
},
});Когда что-то ломается
| Что вы видите | Почему | Что делать |
|---|---|---|
| Сервис отказывается стартовать и жалуется на секрет. | Общий секрет короче 32 символов или его нет вовсе. Короткий секрет почти всегда означает пароль, который кто-то набрал руками. | Сгенерируйте длинный случайный секрет командой openssl rand -base64 48. Положите его в переменную окружения BLOK_SECRET с обеих сторон. У сервиса и у приложения, которое выпускает пропуска, должна быть одна и та же строка. |
| Сервис отказывается стартовать и требует указать origin. | До сервиса может дотянуться кто угодно, а списка тех, кому разрешено к нему обращаться, нет. Такой сервис можно использовать, чтобы слать запросы на чужие сайты от вашего адреса. Так он не запустится. | Передайте --allow-origin с адресом вашего приложения. Несколько адресов перечисляются через запятую. |
| Каждый запрос возвращает 403 «origin not allowed», в том числе запросы из ваших собственных скриптов. | В этом режиме сервис отклоняет всё, что не сообщает, с какого сайта пришло. Браузеры не шлют это для запросов в пределах одного origin. Не-браузерные клиенты не шлют вовсе. | Отправляйте заголовок Origin, совпадающий с одним из значений --allow-origin. Проверьте опечатки: схема и порт тоже участвуют в сравнении. |
| Подписанный пропуск получает превью ссылок, но не может загружать файлы. | Пропуск с write: false может обращаться только к маршруту unfurl. Обоим маршрутам загрузки нужен write: true. | Выпускайте пропуска для загрузки с write: true. Оставляйте write: false, когда вызывающему нужны только превью ссылок. |
| Загрузка работала, а потом стала возвращать 401, после того как вкладка провисела открытой какое-то время. | Пропуск истёк. Именно поэтому его не страшно отдавать браузеру. | Выпускайте новый пропуск на каждый запрос, как это делает настройка загрузчика выше. Срок жизни измеряйте минутами или часами, но не днями. |
| Загрузка продолжает работать, а превью ссылок через час перестают получаться. | Инструмент «Закладка» получает фиксированный набор заголовков в момент создания редактора. Поэтому он продолжает слать тот пропуск, который ему выдали на старте. | Выдайте инструменту «Закладка» пропуск с более долгим сроком жизни. Либо пустите превью ссылок через небольшой маршрут в своём приложении, который подставляет свежий пропуск. |
| Загрузки работают, но сессия совместного редактирования обрывается сразу после открытия. | Пропуск развернули на входе в совместное редактирование. Такой пропуск должен называть открываемый документ в клейме doc. Он также должен быть ещё жив в момент подключения редактора. Пропуск без этого клейма, с другим документом или уже истёкший отклоняется. | Выдавайте пропуски для совместного редактирования с doc, равным открываемому документу. Сначала проверьте, что этому пользователю его можно видеть. Давайте каждому пропуску около 30 минут жизни. |
| Документ открывается только для чтения с пометкой о переподключении и так и не становится редактируемым. | Сервис не смог загрузить документ с ваших маршрутов. Адрес в --doc-endpoint неверен, маршрут не отвечает или отказал. Сервис не станет открывать пустой документ и позже сохранять его поверх вашей записи, поэтому он отказывает сессии. Редактор показывает последнюю известную копию только для чтения и продолжает пробовать. | Проверьте, что --doc-endpoint указывает на ваши маршруты документов. Проверьте, что в BLOK_DOC_ENDPOINT_AUTH лежит значение заголовка, которое они ожидают. Как только маршрут ответит, следующая попытка подключится, и документ станет редактируемым. |