Привет, Хабр!
Эта статья — продолжение первой статьи Telegram как NAS/FTP.
Речь всё о том же боте — TeleFS, он приобрёл важную составляющую — публичность. Точнее, пользователи бота теперь могут делиться своими файлами и папками с любыми другими пользователями Telegram.
И в этот раз расскажем о том как и с помощью чего создавался бот.
Стек
Язык разработки — Java 8
Движок — веб-сервис, на основе фреймворка Play! Framework версии 2.7.
Хранилище — Postgres v10
Маппер — MyBatis (плагин mybatis-guice)
Фронт-сервер — nginx (его задача сводится только к обеспечению tls)
В общем смысле указанный стек абсолютно не критичен. Язык и технологии могут быть использованы любые, жёсткое ограничение из себя представляют только два момента:
- движок должен уметь в http, так как Telegram Bot API — это http-endpoints и получение обновлений всё-таки приятнее без long-poll
- база должна быть реляционной, так как бот эксплуатирует иерархию на основе первичных ключей с подзапросами.
Архитектура хранения
Задача бота — хранить структуры "файловой системы" (ФС) для каждого пользователя.
Идея бота изначально рассматривалась как некое "общее" хранилище для всех пользователей, но после нескольких напряжённых экспериментов была отринута как несостоятельная. Взамен каждый пользователь получил собственную ФС, которая хранится в отдельной плоской таблице, с собственной иерархией. Каждый файл, директория и заметка — это одна физическая запись в таблице владельца, а все представления записи (дочерний узел в ФС, открытый доступ для других пользователей, результат поиска) это всё view (представления) в БД.
Рассмотрим конкретику. Вот таблица одного пользователя (с id 990823086):
Так хранятся физические записи обо всех узлах его файловой системы.
Поверх этой таблицы организовано представление для отображения иерархии:
create view fs_paths_990823086(id, parent_id, owner, path) as
WITH RECURSIVE tree AS (
SELECT fs_user_990823086.id,
fs_user_990823086.name,
fs_user_990823086.parent_id,
fs_user_990823086.owner,
ARRAY [fs_user_990823086.name] AS fpath
FROM fs_user_990823086
WHERE fs_user_990823086.parent_id IS NULL
UNION ALL
SELECT si.id,
si.name,
si.parent_id,
si.owner,
sp.fpath || si.name AS fpath
FROM fs_user_990823086 si
JOIN tree sp ON si.parent_id = sp.id
)
SELECT tree.id,
tree.parent_id,
tree.owner,
array_to_string(tree.fpath, '/'::text) AS path
FROM tree;
Мы получили минимально достаточную структуру хранения для функционала в режиме "каждый сам по себе".
Но наша начальная идея предполагала ведь именно "расшаривание" ресурсов. Эта задача решается через дополнительную физическую таблицу с декларацией разделения доступа и некоторое количество дополнительных view.
Сначала договоримся, что не будем использовать вышупомянутую таблицу в выборках получения контента вообще. Абстрагируемся от неё ещё на один слой, который тоже будет view, но агрегирующим:
create view fs_user_990823086(id, parent_id, name, type, ref_id, options, owner, rw) as
SELECT fs_data_990823086.id,
fs_data_990823086.parent_id,
fs_data_990823086.name,
fs_data_990823086.type,
fs_data_990823086.ref_id,
fs_data_990823086.options,
990823086::bigint AS owner,
true AS rw
FROM fs_data_990823086;
В этом слое добавим то, что необходимо для организации именно разделения доступа: понятие "владельца" и примитивных "прав доступа" в виде rw (read/write). И все элементы ФС теперь будем получать именно из этого слоя.
Следующий шаг предполагает, что некто, желающий поделиться доступом к своей директории, должен каким-то образом выделить кусочек иерархии из своей ФС; кусочек, в котором корнем будет именно эта директория. Делается это очень просто — через очередное представление:
create view fs_share_990823086_990823086_5786da(id, name, type, parent_id, ref_id, options, owner, rw) as
WITH RECURSIVE tree AS (
SELECT fs_data_990823086.id,
fs_data_990823086.name,
fs_data_990823086.type,
'8b990db3-e41a-4130-990d-b3e41a71305a'::uuid AS parent_id,
fs_data_990823086.options,
fs_data_990823086.ref_id
FROM fs_data_990823086
WHERE fs_data_990823086.id = 'c07b37a1-4abf-4a0c-bb37-a14abf6a0c0b'::uuid
UNION ALL
SELECT si.id,
si.name,
si.type,
si.parent_id,
si.options,
si.ref_id
FROM fs_data_990823086 si
JOIN tree sp ON si.parent_id = sp.id
)
SELECT tree.id,
tree.name,
tree.type,
tree.parent_id,
tree.ref_id,
tree.options,
share.owner,
share.rw
FROM tree
LEFT JOIN shares share ON share.id = '5786da'::text;
Затем нам необходимо где-то регулировать доступ к этому кусочку, для этого служит отдельная таблица shares, в которой хранятся именно такие настройки:
Далее, как вы уже наверное догадались, получателю доступа достаточно всего лишь включить это представление в свой слой представления, для того, чтобы чужой "кусочек" встроился в его ФС:
create or replace view fs_user_990823086(id, parent_id, name, type, ref_id, options, owner, rw) as
SELECT fs_data_990823086.id,
fs_data_990823086.parent_id,
fs_data_990823086.name,
fs_data_990823086.type,
fs_data_990823086.ref_id,
fs_data_990823086.options,
990823086::bigint AS owner,
true AS rw
FROM fs_data_990823086
UNION ALL
SELECT fs_share_990823086_990823086_5786da.id,
fs_share_990823086_990823086_5786da.parent_id,
fs_share_990823086_990823086_5786da.name,
fs_share_990823086_990823086_5786da.type,
fs_share_990823086_990823086_5786da.ref_id,
fs_share_990823086_990823086_5786da.options,
fs_share_990823086_990823086_5786da.owner,
fs_share_990823086_990823086_5786da.rw
FROM fs_share_990823086_990823086_5786da;
Конечно же, это финальное представление необходимо перестраивать каждый раз, как пользователь добавляет/удаляет чужие ресурсы, но это совсем небольшая цена за динамическую систему хранения без копирования и проверок целостности.
Движок сервиса
Сервис реализует конечный автомат. В любой момент времени каждый пользователь, условно, находится в одном из 5 основных состояний: "зритель", "редактор", "сторож", "раздающий" и "создатель". Каждый запрос пользователя это либо "новый файл", либо "ввод текста", либо "нажатие на кнопку".
Модель пользователя содержит набор неизменяемых данных (идентификатор telegram-пользователя и идентификатор корневой папки) и аморфное представление атрибутов текущего состояния (роли) в виде json. Каждый запрос извлекает из базы данные пользователя, интерпретирует их в конкретное состояние, согласно маркеру роли, совершает необходимые действия с записями ФС, фиксирует новое состояние и сохраняет обратно в базу. Никаких сессий, никаких авторизаций/токенов, ничего подобного.
Как видим, в самом движке ничего "интеллектуального" нет, он простой.
Несколько слов о Telegram Bot Api
Хотелось бы заострить внимание на некоторых неочевидных моментах Bot API. Не смотря на то, что эти моменты так или иначе описаны в документации, это было упущено при разработке.
- Каждый входящий запрос от Telegram к боту — это уведомление. В документации сам документ и называется Update, но почему-то так не воспринимался. Уведомление, которое не требует какого-то структурированного ответа, только положительного статуса о его, уведомления, приёмке, т.е. пустой OK 200. Не нужно обрабатывать запрос и отдавать ему ответ. Нужно как можно быстрее отдать ему пустой ответ об успешном приёме и дальше заняться своими делами, в том числе — обработкой этого самого уведомления.
- Каждый входящий запрос типа answerCallbackQuery фактически требует асинхронного "ответа" — отдельного вызова Bot API, независимо от всех остальных действий, чтобы пользователь не смотрел на индикатор загрузки.
- Рекомендованный лимит исходящих запросов от бота к API — 1 сообщение в секунду в один чат. Возможно, наука когда-нибудь объяснит избирательность восприятия, но слова про один чат были начисто позабыты во время разработки, осталось только ограничение "1 сообщение в секунду". Впоследствии, при разборе полётов это было исправлено, но это впоследствии, не сразу.
- Любое сообщение в личных диалогах "пользователь/бот" может быть отредактировано в любой момент времени. Хоть через год. А вот удалить можно только в течение 2 суток с момента отправки.
Тут сделаем небольшое отступление, следует пояснить, почему этот момент важен:
бот исповедует подход "одного окна". То есть, по возможности, бот не отсылает пользователю каждый раз новое сообщение, а пытается редактировать содержимое того, что было отослано ранее. Так как, как показала практика полевых испытаний, пользователи путаются в сообщениях, жмут не туда, попадают не туда куда хотели и т.д. Этот подход всем хорош, но иногда бот отсылает т.н. "диалоговые" сообщения, которые не предполагают интерактива и их следует удалять спустя непродолжительное время. Но если пользователь остановился на пол-шаге: например, захотел создать новую папку, получил приглашение на ввод названия и дальше не пошёл, то через 2 суток такое сообщение будет уже неудаляемым. Обработка таких ситуаций пока ещё в планах. - Всего боту доступно два вида сообщений: текстовые и медийные. Текстовые, это которые могут содержать текст и кнопки. Медийные — это которые могут содержать один вид медиа-контента, текст (в 4 раза меньше, чем текстовые сообщения) и кнопки. Типы между собой несовместимы. Это означает, что нельзя сделать ранее отосланное медийное сообщение текстовым. Как понятно из предыдущего пункта, если пользователь только что смотрел файл, а затем перешёл в режим просмотра директории — т.е. было медийное сообщение, а должно стать текстовое — в этом случае бот должен удалить предыдущее и отослать новое
, что расточительно.
Приватность/безопасность
Данный абзац написан потому, что самый частый вопрос про бота в различнейших вариациях сводится к усреднённой форме: "Где лежат мои файлы и кому они доступны".
Сначала немного общей теории про Telegram и файлы в нём.
Когда вы посылаете файл в Telegram (неважно кому именно), если это не "секретный чат", то файл физически закачивается в облако Telegram, где он лежит в зашифрованном виде. Ваш собеседник (например, бот), получает не сам файл, а его уникальный идентификатор и некие атрибуты контента (название, размер и т.д., в зависимости от типа файла). Далее, если этот идентификатор передать в Telegram, то файл станет доступен для скачивания тому, кто его запросил. Но, для того, чтобы можно было скачать файл — этот идентификатор должен быть из вашего диалога. То есть, нельзя взять любой идентификатор из любого диалога и скачать файл. Говоря простыми словами, чтобы кто-то мог скачать ваш файл из облака Telegram — он должен получить его идентификатор от вас через Telegram. Лично, либо через группу/канал и никак иначе.
Теперь к практике, т.е. к боту TeleFS.
То, что вы отсылаете файлы боту — означает, что бот в состоянии создать действующую ссылку для скачивания вашего файла. И в состоянии переслать действующий идентификатор кому-либо из своих собеседников. Бот этого не делает, но он в состоянии это сделать. Что важно — сделать он это может только для своих собеседников. Если кто-то неожиданно получит данные из базы бота, то использовать эти идентификаторы за пределами диалога с этим конкретным ботом он не сможет. То есть, если вы подняли свой экземпляр бота и ваша БД утекла к злоумышленникам — они ничего не смогут получить, даже если поднимут у себя копию такого же бота TFS.
Из чего следует: не храните в публичной структуре TFS ничего личного, секретного и/или очень важного. Поднимите своего персонального бота TFS и храните всё там.
Это всё, о чём хотелось бы рассказать в этот раз.
Всем спасибо за внимание.
ссылки
Бот в Telegram
Бот на GitHub
Инструкция по сборке и установке на свой сервер
Группа для обсуждений/предложений
Agne
А какой бот подойдет? Или модификация данного бота.
Для задачи публикации ссылки из личного облака, канала Телеграмма на файл, и скачивании файла по http.
Lapotx Автор
Вы можете публиковать ссылки из этого бота где угодно.
Ссылки анонимны и обезличены, т.е. потребитель не сможет узнать кому принадлежат файлы.
Аналогов я, честно говоря, не знаю.
Agne
А пользователь без телеграмма, сможет файл по ссылке скачать из браузера? Я что-то сомневаюсь.
Lapotx Автор
нет, конечно без телеграма никак — файлы физически в его облаке.