Всем привет! Меня зовут Алексей Симонов. Я — разработчик в компании ELMA.
Сегодня мы поговорим про протокол под названием WOPI. Он позволяет работать с файлами документов с использованием облачного сервера. Вы выбираете файл который хотите посмотреть или отредактировать — и он тут же открывается в веб-редакторе в вашем браузере. WOPI поддерживают такие продукты, как Onlyoffice, Р7-Офис, Мой Офис, Microsoft Online Office, а также ELMA365, в разработке которой я и участвую.
Внутри нашей Low-code платформы существуют пользовательские модули. Они добавляют системе гибкости за счет расширения функционала. Подробнее о модулях тут. В рамках реализации одного такого модуля я и познакомился с WOPI-протоколом. Задача заключалась в организации работы с файлами с использованием различных облачных серверов документов.
В статье я расскажу о базовых терминах, устройстве протокола и принципах его реализации. Материал будет полезен веб-разработчикам любого уровня, а также их тимлидам для понимания сложности подобной задачи и её декомпозиции.
Что такое WOPI?
WOPI (Web Application Open Platform Interface), как можно понять из перевода — это интерфейс для взаимодействия веб приложений, что важно — открытый. Как ни странно, в названии ничего не говорится о главном объекте, ради которого этот интерфейс существует. Этот объект — файл. Да-да, WOPI — это интерфейс, по нему клиенты (за которыми стоят обычные пользователи) взаимодействуют с сервером, совершая операции над файлами. Обычно это файлы офисных документов — текст, таблицы, отчеты, презентации. С помощью этого протокола можно выполнять следующие действия: чтение, редактирование, создание новых файлов, конвертирование файлов одного формата в другой.
Бегло пройдёмся по термину интерфейс. Что такое интерфейс в программировании? Это набор функций. Если ваш объект реализует все функции в интерфейсе, он реализует интерфейс. Это пригодится нам чуть позже.
Разработан данный протокол был компанией Microsoft в январе 2012 года. Версия 1.0 вышла 8 октября 2012 года. На момент написания статьи актуальная мажорная версия имеет индекс 14.0, выпущена 16 ноября 2021 года. Список версий можно посмотреть здесь.
Как работает WOPI-протокол?
Теперь давайте разберем простейший пример, приведенный в документации, когда пользователь хочет открыть файл на просмотр.
Стандартную схему этого примера можно посмотреть здесь.
Итак, у нас есть 3 сущности — браузер, WOPI-сервер (хост) и WOPI-клиент. И хотя здесь два клиента и один сервер, мы как раз ответственны за последний. Если, конечно, перед нашей системой стоит задача — дать пользователям возможность работы с файлами по WOPI-протоколу. Если же вы разрабатываете, например, облачный сервер документов, то здесь это будет клиент. В данной статье ситуация рассматривается со стороны сервера.
Начинается всё с браузера — пользователь хочет открыть документ на просмотр.
Из браузера отправляется запрос WOPI-серверу, который одновременно является и хранилищем файлов. На сервере есть информация о конкретном WOPI-клиенте. Сервер обращается к клиенту за описанием доступного функционала. Этого запроса нет на оригинальной большой схеме, он описан отдельно и авторы подразумевают что информация уже хранится на сервере. В ответ WOPI-клиент отправляет некий список функций, которые он способен совершить с файлами. Этот список передается в браузер. Каждой функции соответствует URL-адрес. Далее браузер, зная конкретную функцию (просмотр файла) и адрес этой функции на клиенте, обращается по этому адресу.
Вслед за тем начинается более предметный обмен. Сначала клиент запрашивает информацию о файле с сервера, затем сам файл. В результате пользователю в браузере открывается окно клиента с файлом для просмотра. Мы разобрали общий алгоритм. Далее поговорим об основных шагах WOPI-обмена между сервером и клиентом.
WOPI-обмен сервер — клиент
WOPI discovery
WOPI discovery начинается с запроса от сервера на получение функционала клиента:
GET https://<wopi-client-address>/hosting/discovery
В ответ клиент присылает XML-документ, в котором описаны возможные действия с файлами.
<wopi-discovery>
<net-zone name="external-http">
<app name="Word" favIconUrl="https://<favicon_url>/favicon.ico">
<action name="view" ext="pdf" urlsrc="https://<action_url>?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
<action name="edit" ext="docx" default="true" requires="locks,update" urlsrc="https:/<action_url>?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
</app>
</net-zone>
</wopi-discovery>
Структура документа:
<wopi-discovery>
<app name = … >
<action name = … requires = … ext = … urlsrc = …/>
</app>
</wopi-discovery>
Корневым тегом в таком документе является wopi-discovery. Каждое действие с файлом отмечено тегом action. Действия собраны в группы по признаку приложения, которым обрабатываются файлы. Например, app=world для doc, docx, app=excel для xls, xlsx.
<action name="view" ext="pdf" urlsrc="https://<action_url>?&<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&><fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&><sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&><wopisrc=WOPI_SOURCE&>&"/>
Действие содержит в себе атрибуты:
name — описание действия
ext (extension) — расширение файла для действия
requires — атрибут, описывающий множество функций WOPI, которые должен реализовывать ваш сервер для корректной работы действия.
urlsrc — адрес, по которому доступно действие WOPI-клиента.
Для name характерны два основных значения view (просмотр), edit (редактирование). Также могут встречаться editnew (создание пустого файла и последующее редактирование), convert (конвертация устаревших форматов в современные, например doc в docx, для возможности редактирования).
Среди значений атрибута requires встречаются:
update подразумевает под собой функции PutFile и PutRelativeFile
locks включает функции Lock, RefreshLoсk, Unlock, UnlockAndRelock.
О самих функциях мы поговорим чуть позже.
Адрес urlsrc может включать в себя placeholder values, проще говоря, параметры, которые можно дополнительно настроить при вызове определенного действия. Среди них могут встретиться:
ui — язык интерфейса,
dchat (disable chat) — управление отображением чата внутри файла.
Существует так же один обязательный параметр wopisrc. В нём вы должны указать общий URL-адрес, по которому ваш сервер предоставляет вызовы WOPI-функций. Сейчас предметно с ним разберемся.
WOPI source
Он же wopisrc. Это точка входа для обращения к функционалу вашего WOPI-сервера.
Для этого адреса определено два правила:
Он должен заканчиваться не иначе, как:
<адрес сервера>/.../wopi.../files
Чтобы было понятнее, приведу примеры с пояснениями.
https://my-wopi-server.com/my-wopi/files/… — неверно, т. к. часть URL, отвечающая за точку входа начинается не с «wopi». В то же время
https://my-wopi-server.com/wopi-my/files/… — верно.
После /wopi.../files должен следовать id файла, вот так
<адрес сервера>/wopi.../files/<id>
Второе правило на самом деле не такое строгое. Id не обязательно должен быть идентификатором файла. На месте id может быть любая информация, на обработку которой рассчитан ваш сервер, но внутри этой информации обязательно должен быть id файла. Сам id файла нужен, чтобы однозначно определить файл, для которого вызвано действие. Таким образом второе правило можно трактовать так:
<адрес сервера>/wopi.../files/<какая-то информация, содержащая в себе id файла, которую ваш сервер в состоянии обработать в формате, в котором её можно встроить в url>
WOPI host page
Что нужно, чтобы на сервере отобразить окно редактора клиента с содержимым файла?
Создаём HTML-страничку вида:
<!--Форма которую нужно отправить для инициализации редактора-->
<!--id - идентификатор формы, нужен для отправки формы-->
<!--action - нужный нам urlsrc обязательно с заполненным параметром wopisrc указывающим на точку входа на наш сервер-->
<!--access_token - токен сформированный сервером для идентификации клиента-->
<!--access_token_ttl - время жизни токена, указывать его не обязательно-->
<form
id="office_form"
name="office_form"
target="office_frame"
action="<%= actionUrl %>"
method="post">
<input name="access_token" value="<%= token %>" type="hidden" />
<input name="access_token_ttl" value="<%= tokenTtl %>" type="hidden" />
</form>
<!--Элемент на странице к которому будет привязан iframe с редактором-->
<span id="frameholder"></span>
<!--Скрипт для создания iframe-->
<!--В нём будет отрисован компонент редактора wopi клиента после отправки формы-->
<script type="text/javascript">
var frameholder = document.getElementById('frameholder');
var office_frame = document.createElement('iframe');
office_frame.name = 'office_frame';
office_frame.id = 'office_frame';
office_frame.title = 'Office Frame';
office_frame.setAttribute('allowfullscreen', 'true');
office_frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox allow-downloads allow-modals');
office_frame.setAttribute('allow', 'autoplay camera microphone display-capture');
frameholder.appendChild(office_frame);
document.getElementById('office_form').submit();
</script>
Чтобы это всё заработало, нам нужно заполнить все поля на форме и отправить её (выполнить submit). Если мы не допустили ошибок, в ответ в iframe загрузится редактор WOPI-клиента с открытым в нём файлом. Будет представлен функционал того действия urlsrc, которое мы указали (находится в теге action документа discovery).
Как сделать WOPI-обмен безопасным
Для идентификации источника запросов (или пользователя), а также прав пользователя на файл, для которого приходит запрос в протоколе, существует токен (access_token).
Такой токен создается сервером и отправляется на клиент при инициализации сессии. Что делает клиент с этим токеном? Он присылает его обратно в каждом своём запросе параметром (query). Задача сервера — обработать токен и вернуть корректные данные на запрос, либо ошибку авторизации, если валидация токена не увенчалась успехом.
Есть возможность ещё больше обезопасить наш WOPI-обмен. Для этого существует заголовок X-WOPI-PROOF-KEYS. Клиент подписывает каждый свой запрос закрытым ключом. Открытый ключ можно найти в WOPI discovery по тегу proof-key. В общих чертах, чтобы подтвердить оригинальность клиента на сервере, нужно по специальному алгоритму рассчитать эталонный ключ, расшифровать закрытый ключ из заголовка X-WOPI-PROOF с помощью информации из тэга prof-key и сравнить их. Ключи должны совпасть. Реализация этой проверки не обязательна.
Распространённые заголовки WOPI-запросов
Когда клиент отправляет на наш сервер запросы, обычно он снабжает их служебными заголовками. Если обратиться к документации, то в секции, где описаны заголовки, большинство из них служит для нужд логирования. Описывать их здесь я не вижу смысла. Можете пойти сюда и ознакомиться.
Подчеркну только один заголовок, не приведенный по ссылке выше: X-WOPI-OVERRIDE содержит строку «название операции в текущем запросе». Этот заголовок очень важен в случае, когда для нескольких операций в наличии только один адрес.
Коды ответов WOPI-запросов
Перечислю основные HTTP-коды, которые наш сервер может отправлять в ответ на запросы WOPI-клиента и приведу ссылки на информацию о них.
200 ОК — запрос выполнен успешно: https://www.rfc-editor.org/rfc/rfc9110.html#name-200-ok
400 Bad Request — неверные данные от клиента: https://www.rfc-editor.org/rfc/rfc9110.html#name-400-bad-request
401 Unauthorized — неверное значение access_token: https://www.rfc-editor.org/rfc/rfc9110.html#name-401-unauthorized
404 Not Found — сервер не может найти данные или пользователь не авторизован: https://www.rfc-editor.org/rfc/rfc9110.html#name-404-not-found
409 Conflict — текущее состояние объекта на сервере противоречит запросу: https://www.youtube.com/watch?v=mzqLONQxfqQ
412 Precondition Failed — одно из значений переданных в заголовках запроса противоречит условиям на сервере: https://www.rfc-editor.org/rfc/rfc9110.html#name-412-precondition-failed
413 Content Too Large — содержимое запроса больше, чем сервер может обработать: https://www.rfc-editor.org/rfc/rfc9110.html#name-413-content-too-large
500 Internal Server Error — неожиданная ошибка на сервере: https://www.rfc-editor.org/rfc/rfc9110.html#name-500-internal-server-error
501 Not Implemented - Отсутствует функционал на сервере: https://www.rfc-editor.org/rfc/rfc9110.html#name-501-not-implemente
Функции для работы с файлами
Эти функции должны быть реализованы на сервере для корректного взаимодействия с WOPI-клиентом. Описание в документации тут.
Адрес функции |
Название функции |
Значение X-WOPI-OVERRIDE |
Реализация чтения файла |
Реализация чтения и редактирования файла |
GET <адрес сервера>/wopi…/files/<id> |
CheckFileInfo |
да |
да |
|
POST <адрес сервера>/wopi…/files/<id> |
Lock |
LOCK |
нет |
да |
GetLock |
GET_LOCK |
нет |
нет |
|
PutRelativeFile |
PUT_RELATIVE |
нет |
нет |
|
Unlock |
UNLOCK |
нет |
да |
|
RefreshLock |
REFRESH_LOCK |
нет |
да |
|
UnlockAndRelock |
LOCK + X-WOPI-OLD-LOCK |
нет |
да |
|
DeleteFile |
DELETE |
нет |
нет |
|
RenameFile |
RENAME_FILE |
нет |
нет |
|
GET <адрес сервера>/wopi…/files/<id>/contents |
GetFile |
да |
да |
|
POST<адрес сервера>/wopi…/files/<id>/contents |
PutFile |
PUT |
нет |
да |
GET <адрес сервера>/wopi.../containers/<id> |
CheckContainerInfo |
нет |
нет |
|
POST <адрес сервера>/wopi.../containers/<id> |
CreateChildContainer |
CREATE_CHILD_CONTAINER |
нет |
нет |
CreateChildFile |
CREATE_CHILD_FILE |
нет |
нет |
|
DeleteContainer |
DELETE_CONTAINER |
нет |
нет |
|
RenameContainer |
RENAME_CONTAINER |
нет |
нет |
|
GET <адрес сервера>/wopi.../containers/<id>/ancestry |
EnumerateAncestors (containers) |
нет |
нет |
|
GET <адрес сервера>/wopi.../containers/<id>/children |
EnumerateChildren (containers) |
нет |
нет |
Адреса функций, указанные в таблице, должны строго соответствовать: по ним WOPI-клиент будет обращаться, вызывая ту или иную функцию. А вот названия функций можно делать произвольными. Какие они — снаружи не видно!
Для реализации чтения файла по WOPI-протоколу достаточно реализовать на сервере всего две функции:
CheckFileInfo
GetFile
Если вы хотите, чтобы ваш сервер поддерживал не только просмотр файлов, но и редактирование, придется добавить реализацию функций:
Lock
Unlock
UnlockAndRelock
RefreshLock
PutFile
Поговорим о каждой из них подробнее.
CheckFileInfo
GET <адрес сервера>/wopi.../files/<id>
Тот самый первый запрос от клиента на сервер за информацией о файле, с которым будем работать. По id в конце адреса наш сервер должен быть способен найти нужный файл. В ответе — JSON структура, содержащая в себе поля, например:
BaseFileName — имя файла
Version — версия файла
OwnerId — идентификатор пользователя создавшего файл
Size — размер файла в байтах
UserId — идентификатор пользователя, от имени которого совершается запрос
UserFriendlyName — имя пользователя, который совершает запрос
SupportsLocks — реализованы ли на сервере функции блокировок
UserCanWrite — может ли текущий пользователь редактировать файл
DisablePrint — возможность отключить кнопку печати в окне WOPI-клиента
DownloadUrl — ссылка по которой можно скачать файл
GetFile
GET <адрес сервера>/wopi.../files/<id>/contents
По этому адресу клиент может получить файл от сервера. Тем не менее в зависимости от реализации иногда клиент может воспользоваться информацией из поля DownloadUrl функции CheckFileInfo. Но эта функция в любом случае должна быть реализована.
PutFile
POST <адрес сервера>/wopi.../files/<id>/contents
Заголовок X-WOPI-OVERRIDE со значением PUT.
Функция, при вызове которой сервер должен обновить содержимое файла из тела запроса. Вызывается после завершения редактирования. Обычно клиенты перед вызовом этой функции блокируют файл функцией Lock. Заменять файл следует только если в заголовке X-WOPI-LOCK запроса содержится то же значение которое было задано функцией Lock. В противном случае — 409 Conflict с возвратом текущего идентификатора блокировки в заголовке X-WOPI-LOCK.
Функции блокировок
Lock
POST <адрес сервера>/wopi.../files/<id>
Заголовок X-WOPI-OVERRIDE со значением LOCK.
Эта функция служит для блокировки файла. Когда пользователь открывает файл на редактирование — клиент после получения файла посылает запрос блокировки, чтобы предотвратить редактирование файла кем-либо ещё. В запросе обязательно должен прийти заголовок X-WOPI-OVERRIDE со значением LOCK. Вместе с ним клиент посылает второй — X-WOPI-LOCK который содержит строковый идентификатор блокировки. Идентификатор блокировки служит для того, чтоб однозначно определить какой клиент заблокировал файл.
Если к моменту на сервере для файла нет идентификатора блокировки, то сервер должен заблокировать файл и запомнить идентификатор.
Если идентификатор есть, и он соответствует значению из запроса, то нужно обновить блокировку — выполнить функцию RefreshLock. В остальных случаях сервер должен вернуть статус 409 Conflict и поместить в ответ заголовок X-WOPI-LOCK со значением текущей блокировки файла. Блокировка автоматически истекает через 30 минут.
GetLock
POST <адрес сервера>/wopi.../files/<id>
Заголовок X-WOPI-OVERRIDE со значением GET_LOCK.
Дает возможность получить текущее значение идентификатора блокировки. Если файл не заблокирован — в заголовке X-WOPI-LOCK отправляется пустая строка. Если блокировка есть — значение идентификатора. Если текущий идентификатор по каким либо причинам не подходит по формату — 409 Conflict.
RefreshLock
POST <адрес сервера>/wopi.../files/<id>
Заголовок X-WOPI-OVERRIDE со значением REFRESH_LOCK.
Данная функция продлевает действие текущей блокировки. Обычно WOPI-клиенты предпочитают этой функции повторный вызов Lock. Если идентификатор блокировки не равен полученному из запроса — файл перехвачен кем-то другим. В таком случае нужно ответить статусом 409 Conflict и вернуть текущий идентификатор. Если файл никем не заблокирован нужно вернуть в качестве идентификатор пустую строку.
Unlock
POST <адрес сервера>/wopi.../files/<id>
Заголовок X-WOPI-OVERRIDE со значением UNLOCK.
Сигнализирует о том, что редактирование завершено и файл может быть освобожден от блокировки. Если идентификатор из заголовка запроса X-WOPI-LOCK соответствует текущему — блокировка снимается и в ответе мы должны в ответе вернуть X-WOPI-LOCK со значением пустой строки. Если текущий идентификатор не равен значению из запроса — 409 Conflict и вернуть текущий идентификатор. Если файл уже разблокирован – поступаем так же, но заголовок X-WOPI-LOCK оставляем пустым.
UnlockAndRelock
POST <адрес сервера>/wopi.../files/<id>
Заголовок X-WOPI-OVERRIDE со значением LOCK.
Функция позволяет установить новую блокировку с заданным значением. По своей сути идентична функции Lock. Различить их можно по заголовкам. В Lock текущий идентификатор передается в заголовке X-WOPI-LOCK, здесь же этот заголовок используется для задания нового идентификатора, а текущий содержится в X-WOPI-OLD-LOCK. В ответ заголовок X-WOPI-LOCK следует включать только в том случае, если что-то пошло не так и присваивать ему текущее значение блокировки. При успешном прохождении запроса нужно вернуть ответ 200 Ok.
С использованием остальных функций я не сталкивался, поэтому рассказывать про них не буду. Как всегда, в случае чего можно просмотреть официальную документацию.
Теперь, когда вы познакомились с деталями реализации WOPI-протокола, диаграмму открытия файла на просмотр можно преобразовать, заменив описательные действия над стрелками определенными функциями:
Пример из практики
Если вы внимательно читали начало статьи — всю работу с файлами по WOPI-протоколу мы реализовали в модуле. Был создан виджет по образу и подобию host page, но с некоторыми изменениями. В методах API модуля были реализованы необходимые функции для возможности чтения и редактирования файлов. Список функций можно найти в таблице выше. Для внедрения токенов в WOPI-обмен пришлось немного допилить саму платформу ELMA365. Проделав эту работу, нам удалось получить средство для работы с файлами по WOPI-протоколу с использованием различных облачных серверов документов. Ниже представлен интерфейс реактора OnlyOffice, загруженный в виджете WOPI-модуля внутри системы ELMA365.
Вместо заключения
Говоря о плюсах и минусах протокола WOPI, для себя я выделил два основных преимущества — универсальность и быстрота реализации. Под универсальностью я подразумеваю возможность прикрутить любой реализующий протокол сервер документов в качестве WOPI-клиента. Просто меняешь адрес клиента, и вуаля — в браузере уже открыт редактор от нового поставщика. Я успел поэкспериментировать с продуктами Мой Офис, Onlyoffice и MS Online Office. И все они работали плюс-минус одинаково. Быстрота реализации заключается в том, что на самом деле, функций, которые нужно реализовать для работы немного. Только вдумайтесь — для чтения файла нужно всего две! Зная все тонкости и хитрости для создания MVP хватит и одного дня (не хватит), ну может быть двух.
Минусы у такого решения конечно тоже присутствуют. Первый и главный из них — любая универсальная штука очень угловата и плохо кастомизируется. Так и с этим протоколом. Он поддерживает минимально необходимое для работы с файлами количество операций. Настроек интерфейса на моей памяти всего одна – показывать или нет кнопку печати. А к уникальным фишкам конкретного продукта вообще доступ не получить.
Второй минус, который может быть для кого-то критичным — WOPI работает априори медленнее чем API, потому что зачастую под капотом WOPI крутится тот же API. Не всегда отличие в производительности видно невооруженным глазом, но как это бывает по закону подлости — ружье может выстрелить в самый ненужный момент.
Если у вас есть опыт работы с WOPI, делитесь в комментариях.