diff --git a/example-config.yaml b/example-config.yaml index 27c64991..69c832f3 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -33,6 +33,17 @@ appservice: # implicitly. external: https://example.com/public + # Provisioning API part of the web server for automated portal creation and fetching information. + # Used by things like Dimension (https://dimension.t2bot.io/). + provisioning: + # Whether or not the provisioning API should be enabled. + enabled: true + # The prefix to use in the provisioning API endpoints. + prefix: /_matrix/provision/v1 + # The shared secret to authorize users of the API. + # If you leave the default token, a random token will be generated and saved at startup. + shared_secret: "Very secret shared secret" + # The unique ID of this appservice. id: telegram # Username of the appservice bot. @@ -228,6 +239,8 @@ logging: level: DEBUG telethon: level: DEBUG + aiohttp: + level: INFO root: level: DEBUG handlers: [file, console] diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index 0d06f101..430696eb 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -38,7 +38,8 @@ from .bot import init as init_bot from .portal import init as init_portal from .puppet import init as init_puppet from .formatter import init as init_formatter -from .public import PublicBridgeWebsite +from .web.public import PublicBridgeWebsite +from .web.provisioning import ProvisioningAPI from .context import Context from .sqlstatestore import SQLStateStore @@ -75,9 +76,9 @@ db_factory = orm.sessionmaker(bind=db_engine) db_session = orm.scoping.scoped_session(db_factory) Base.metadata.bind = db_engine -telethon_session_container = AlchemySessionContainer(engine=db_engine, session=db_session, - table_base=Base, table_prefix="telethon_", - manage_tables=False) +session_container = AlchemySessionContainer(engine=db_engine, session=db_session, + table_base=Base, table_prefix="telethon_", + manage_tables=False) loop = asyncio.get_event_loop() @@ -87,11 +88,20 @@ appserv = AppService(config["homeserver.address"], config["homeserver.domain"], config["appservice.bot_username"], log="mau.as", loop=loop, verify_ssl=config["homeserver.verify_ssl"], state_store=state_store) -context = Context(appserv, db_session, config, loop, None, None, telethon_session_container) +public_website = None +provisioning_api = None if config["appservice.public.enabled"]: - public = PublicBridgeWebsite(loop) - appserv.app.add_subapp(config.get("appservice.public.prefix", "/public"), public.app) + public_website = PublicBridgeWebsite(loop) + appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app) + +if config["appservice.provisioning.enabled"]: + provisioning_api = ProvisioningAPI(config, appserv, loop) + appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning", + provisioning_api.app) + +context = Context(appserv, db_session, config, loop, None, None, session_container, public_website, + provisioning_api) with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start: init_db(db_session) diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index dd1fe03b..1095e993 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -96,9 +96,9 @@ class AbstractUser: except Exception: self.log.exception("Failed to handle Telegram update") - async def _get_dialogs(self, limit=None): + async def get_dialogs(self, limit=None) -> List[Union[Chat, Channel]]: if self.is_bot: - return + return [] dialogs = await self.client.get_dialogs(limit=limit) return [dialog.entity for dialog in dialogs if ( not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden)) @@ -328,5 +328,5 @@ class AbstractUser: def init(context): global config, MAX_DELETIONS AbstractUser.az, AbstractUser.db, config, AbstractUser.loop, _ = context - AbstractUser.session_container = context.telethon_session_container + AbstractUser.session_container = context.session_container MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10) diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index 4c6b135c..67c7ab64 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -114,7 +114,7 @@ async def login(evt: CommandEvent): if evt.config["appservice.public.enabled"]: prefix = evt.config["appservice.public.external"] - url = f"{prefix}/login?mxid={evt.sender.mxid}" + url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid)}" if evt.config.get("bridge.allow_matrix_login", True): return await evt.reply( "This bridge instance allows you to log in inside or outside Matrix.\n\n" diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py index 7bf4323d..53a71a4b 100644 --- a/mautrix_telegram/commands/handler.py +++ b/mautrix_telegram/commands/handler.py @@ -22,8 +22,7 @@ import logging from telethon.errors import FloodWaitError from ..util import format_duration -from ..context import Context -from .. import user as u +from .. import user as u, context as c command_handlers = {} # type: Dict[str, CommandHandler] @@ -45,6 +44,7 @@ class CommandEvent: self.loop = processor.loop self.tgbot = processor.tgbot self.config = processor.config + self.public_website = processor.public_website self.command_prefix = processor.command_prefix self.room_id = room self.sender = sender @@ -131,8 +131,9 @@ def command_handler(_func: Optional[Callable[[CommandEvent], None]] = None, *, n class CommandProcessor: log = logging.getLogger("mau.commands") - def __init__(self, context: Context): + def __init__(self, context: c.Context): self.az, self.db, self.config, self.loop, self.tgbot = context + self.public_website = context.public_website self.command_prefix = self.config["bridge.command_prefix"] async def handle(self, room: str, sender: u.User, command: str, args: List[str], diff --git a/mautrix_telegram/commands/portal.py b/mautrix_telegram/commands/portal.py index c87ade32..38998224 100644 --- a/mautrix_telegram/commands/portal.py +++ b/mautrix_telegram/commands/portal.py @@ -19,10 +19,11 @@ import asyncio from telethon.errors import * from telethon.tl.types import ChatForbidden, ChannelForbidden -from mautrix_appservice import MatrixRequestError +from mautrix_appservice import MatrixRequestError, IntentAPI from .. import portal as po, user as u -from . import command_handler, CommandEvent, SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT +from . import (command_handler, CommandEvent, + SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT) @command_handler(needs_admin=True, needs_auth=False, name="set-pl", @@ -65,7 +66,7 @@ async def invite_link(evt: CommandEvent): return await evt.reply("You don't have the permission to create an invite link.") -async def _has_access_to(room: str, intent, sender: u.User, event: str, default: int = 50): +async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50): if sender.is_admin: return True # Make sure the state store contains the power levels. @@ -87,7 +88,7 @@ async def _get_portal_and_check_permission(evt: CommandEvent, permission: str, that_this = "This" if room_id == evt.room_id else "That" return await evt.reply(f"{that_this} is not a portal room."), False - if not await _has_access_to(portal.mxid, evt.az.intent, evt.sender, permission): + if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission): action = action or f"{permission.replace('_', ' ')}s" return await evt.reply(f"You do not have the permissions to {action} that portal."), False return portal, True @@ -116,7 +117,7 @@ def _get_portal_murder_function(action: str, room_id: str, function: Callable, c "Only works for group chats; to delete a private chat portal, simply " "leave the room.") async def delete_portal(evt: CommandEvent): - portal, ok = await _get_portal_and_check_permission(evt, "delete_portal") + portal, ok = await _get_portal_and_check_permission(evt, "unbridge") if not ok: return @@ -137,7 +138,7 @@ async def delete_portal(evt: CommandEvent): help_section=SECTION_PORTAL_MANAGEMENT, help_text="Remove puppets from the current portal room and forget the portal.") async def unbridge(evt: CommandEvent): - portal, ok = await _get_portal_and_check_permission(evt, "unbridge_room") + portal, ok = await _get_portal_and_check_permission(evt, "unbridge") if not ok: return @@ -166,8 +167,8 @@ async def bridge(evt: CommandEvent): if portal: return await evt.reply(f"{that_this} room is already a portal room.") - if not await _has_access_to(room_id, evt.az.intent, evt.sender, "bridge"): - return await evt.reply("You do not have the permissions to bridge that room.") + if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"): + return await evt.reply(f"You do not have the permissions to bridge {that_this} room.") # The /id bot command provides the prefixed ID, so we assume tgid = evt.args[0] @@ -192,7 +193,7 @@ async def bridge(evt: CommandEvent): has_portal_message = ( "That Telegram chat already has a portal at " f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ") - if not await _has_access_to(portal.mxid, evt.az.intent, evt.sender, "unbridge"): + if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"): return await evt.reply(f"{has_portal_message}" "Additionally, you do not have the permissions to unbridge " "that room.") @@ -291,7 +292,7 @@ async def confirm_bridge(evt: CommandEvent): direct = False portal.mxid = bridge_to_mxid - portal.title, portal.about, levels = await _get_initial_state(evt) + portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id) portal.photo_id = "" portal.save() @@ -301,8 +302,8 @@ async def confirm_bridge(evt: CommandEvent): return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") -async def _get_initial_state(evt: CommandEvent): - state = await evt.az.intent.get_room_state(evt.room_id) +async def get_initial_state(intent: IntentAPI, room_id: str): + state = await intent.get_room_state(room_id) title = None about = None levels = None @@ -336,7 +337,10 @@ async def create(evt: CommandEvent): if po.Portal.get_by_mxid(evt.room_id): return await evt.reply("This is already a portal room.") - title, about, levels = await _get_initial_state(evt) + if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): + return await evt.reply("You do not have the permissions to bridge this room.") + + title, about, levels = await get_initial_state(evt.az.intent, evt.room_id) if not title: return await evt.reply("Please set a title before creating a Telegram chat.") diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index fb2e14a2..77cc6bfb 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -159,6 +159,12 @@ class Config(DictWithRecursion): copy("appservice.public.prefix") copy("appservice.public.external") + copy("appservice.provisioning.enabled") + copy("appservice.provisioning.prefix") + copy("appservice.provisioning.shared_secret") + if base["appservice.provisioning.shared_secret"] == "Very secret shared secret": + base["appservice.provisioning.shared_secret"] = self._new_token() + copy("appservice.id") copy("appservice.bot_username") copy("appservice.bot_displayname") @@ -244,7 +250,7 @@ class Config(DictWithRecursion): puppeting = level == "full" or admin user = level == "user" or puppeting relaybot = level == "relaybot" or user - return relaybot, user, puppeting, admin + return relaybot, user, puppeting, admin, level def get_permissions(self, mxid): permissions = self["bridge.permissions"] or {} diff --git a/mautrix_telegram/context.py b/mautrix_telegram/context.py index 530f1eed..ad48d7e4 100644 --- a/mautrix_telegram/context.py +++ b/mautrix_telegram/context.py @@ -14,17 +14,30 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import asyncio + +from sqlalchemy.orm import scoped_session +from alchemysession import AlchemySessionContainer +from mautrix_appservice import AppService class Context: - def __init__(self, az, db, config, loop, bot, mx, telethon_session_container): - self.az = az - self.db = db - self.config = config - self.loop = loop - self.bot = bot - self.mx = mx - self.telethon_session_container = telethon_session_container + def __init__(self, az, db, config, loop, bot, mx, session_container, public_website, + provisioning_api): + from .web import PublicBridgeWebsite, ProvisioningAPI + from .config import Config + from .bot import Bot + from .matrix import MatrixHandler + + self.az = az # type: AppService + self.db = db # type: scoped_session + self.config = config # type: Config + self.loop = loop # type: asyncio.AbstractEventLoop + self.bot = bot # type: Bot + self.mx = mx # type: MatrixHandler + self.session_container = session_container # type: AlchemySessionContainer + self.public_website = public_website # type: PublicBridgeWebsite + self.provisioning_api = provisioning_api # type: ProvisioningAPI def __iter__(self): yield self.az diff --git a/mautrix_telegram/formatter/__init__.py b/mautrix_telegram/formatter/__init__.py index 7cb102f7..51802ebb 100644 --- a/mautrix_telegram/formatter/__init__.py +++ b/mautrix_telegram/formatter/__init__.py @@ -1,9 +1,9 @@ from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram, init_mx) from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg) -from ..context import Context +from .. import context as c -def init(context: Context): +def init(context: c.Context): init_mx(context) init_tg(context) diff --git a/mautrix_telegram/formatter/from_matrix.py b/mautrix_telegram/formatter/from_matrix.py index 145177fc..f98d3ad5 100644 --- a/mautrix_telegram/formatter/from_matrix.py +++ b/mautrix_telegram/formatter/from_matrix.py @@ -27,8 +27,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M MessageEntityItalic, MessageEntityCode, MessageEntityPre, MessageEntityBotCommand, TypeMessageEntity) -from ..context import Context -from .. import user as u, puppet as pu, portal as po +from .. import user as u, puppet as pu, portal as po, context as c from ..db import Message as DBMessage from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, trim_reply_fallback_text, html_to_unicode) @@ -352,7 +351,7 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], st return entities, replacer -def init_mx(context: Context): +def init_mx(context: c.Context): global plain_mention_regex, should_bridge_plaintext_highlights config = context.config dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)") diff --git a/mautrix_telegram/formatter/from_telegram.py b/mautrix_telegram/formatter/from_telegram.py index 3e9992e4..70a13a55 100644 --- a/mautrix_telegram/formatter/from_telegram.py +++ b/mautrix_telegram/formatter/from_telegram.py @@ -33,8 +33,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, from mautrix_appservice import MatrixRequestError from mautrix_appservice.intent_api import IntentAPI -from .. import user as u, puppet as pu, portal as po -from ..context import Context +from .. import user as u, puppet as pu, portal as po, context as c from ..db import Message as DBMessage from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, trim_reply_fallback_text, unicode_to_html) @@ -321,6 +320,6 @@ def _parse_url(html: List[str], entity_text: str, url: str) -> bool: return False -def init_tg(context: Context): +def init_tg(context: c.Context): global should_highlight_edits should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"] diff --git a/mautrix_telegram/public/__init__.py b/mautrix_telegram/public/__init__.py deleted file mode 100644 index 6a463f2e..00000000 --- a/mautrix_telegram/public/__init__.py +++ /dev/null @@ -1,184 +0,0 @@ -# -*- coding: future_fstrings -*- -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2018 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from aiohttp import web -from mako.template import Template -import asyncio -import pkg_resources -import logging - -from telethon.errors import * - -from ..user import User -from ..commands.auth import enter_password -from ..util import format_duration - - -class PublicBridgeWebsite: - log = logging.getLogger("mau.public") - - def __init__(self, loop): - self.loop = loop - - self.login = Template( - pkg_resources.resource_string("mautrix_telegram", "public/login.html.mako")) - - self.app = web.Application(loop=loop) - self.app.router.add_route("GET", "/login", self.get_login) - self.app.router.add_route("POST", "/login", self.post_login) - self.app.router.add_static("/", - pkg_resources.resource_filename("mautrix_telegram", "public/")) - - async def get_login(self, request): - user = (User.get_by_mxid(request.rel_url.query["mxid"], create=False) - if "mxid" in request.rel_url.query else None) - state = "token" if request.rel_url.query.get("mode", "") == "bot" else "request" - if not user: - return self.render_login( - mxid=request.rel_url.query["mxid"] if "mxid" in request.rel_url.query else None, - state=state) - elif not user.puppet_whitelisted: - return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403) - await user.ensure_started() - if not await user.is_logged_in(): - return self.render_login(mxid=user.mxid, state=state) - - return self.render_login(mxid=user.mxid, username=user.username) - - def render_login(self, status=200, username="", state="", error="", message="", mxid=""): - return web.Response(status=status, content_type="text/html", - text=self.login.render(username=username, state=state, error=error, - message=message, mxid=mxid)) - - async def post_login_token(self, user, token): - try: - user_info = await user.client.sign_in(bot_token=token) - asyncio.ensure_future(user.post_login(user_info), loop=self.loop) - if user.command_status and user.command_status["action"] == "Login": - user.command_status = None - return self.render_login(mxid=user.mxid, state="logged-in", status=200, - username=user_info.username) - except Exception: - self.log.exception("Error sending bot token") - return self.render_login(mxid=user.mxid, state="token", status=500, - error="Internal server error while sending token.") - - async def post_login_phone(self, user, phone): - try: - await user.client.sign_in(phone or "+123") - return self.render_login(mxid=user.mxid, state="code", status=200, - message="Code requested successfully.") - except PhoneNumberInvalidError: - return self.render_login(mxid=user.mxid, state="request", status=400, - error="Invalid phone number.") - except PhoneNumberUnoccupiedError: - return self.render_login(mxid=user.mxid, state="request", status=404, - error="That phone number has not been registered.") - except PhoneNumberFloodError: - return self.render_login( - mxid=user.mxid, state="request", status=429, - error="Your phone number has been temporarily blocked for flooding. " - "The ban is usually applied for around a day.") - except FloodWaitError as e: - return self.render_login( - mxid=user.mxid, state="request", status=429, - error="Your phone number has been temporarily blocked for flooding. " - f"Please wait for {format_duration(e.seconds)} before trying again.") - except PhoneNumberBannedError: - return self.render_login(mxid=user.mxid, state="request", status=401, - error="Your phone number is banned from Telegram.") - except PhoneNumberAppSignupForbiddenError: - return self.render_login(mxid=user.mxid, state="request", status=401, - error="You have disabled 3rd party apps on your account.") - except Exception: - self.log.exception("Error requesting phone code") - return self.render_login(mxid=user.mxid, state="request", status=500, - error="Internal server error while requesting code.") - - async def post_login_code(self, user, code, password_in_data): - try: - user_info = await user.client.sign_in(code=code) - asyncio.ensure_future(user.post_login(user_info), loop=self.loop) - if user.command_status and user.command_status["action"] == "Login": - user.command_status = None - return self.render_login(mxid=user.mxid, state="logged-in", status=200, - username=user_info.username) - except PhoneCodeInvalidError: - return self.render_login(mxid=user.mxid, state="code", status=403, - error="Incorrect phone code.") - except PhoneCodeExpiredError: - return self.render_login(mxid=user.mxid, state="code", status=403, - error="Phone code expired.") - except SessionPasswordNeededError: - if not password_in_data: - if user.command_status and user.command_status["action"] == "Login": - user.command_status = { - "next": enter_password, - "action": "Login (password entry)", - } - return self.render_login( - mxid=user.mxid, state="password", status=200, - message="Code accepted, but you have 2-factor authentication is enabled.") - return None - except Exception: - self.log.exception("Error sending phone code") - return self.render_login(mxid=user.mxid, state="code", status=500, - error="Internal server error while sending code.") - - async def post_login_password(self, user, password): - try: - user_info = await user.client.sign_in(password=password) - asyncio.ensure_future(user.post_login(user_info), loop=self.loop) - if user.command_status and user.command_status["action"] == "Login (password entry)": - user.command_status = None - return self.render_login(mxid=user.mxid, state="logged-in", status=200, - username=user_info.username) - except (PasswordHashInvalidError, PasswordEmptyError): - return self.render_login(mxid=user.mxid, state="password", status=400, - error="Incorrect password.") - except Exception: - self.log.exception("Error sending password") - return self.render_login(mxid=user.mxid, state="password", status=500, - error="Internal server error while sending password.") - - async def post_login(self, request): - data = await request.post() - if "mxid" not in data: - return self.render_login(error="Please enter your Matrix ID.", status=400) - - user = await User.get_by_mxid(data["mxid"]).ensure_started() - if not user.puppet_whitelisted: - return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403) - elif await user.is_logged_in(): - return self.render_login(mxid=user.mxid, username=user.username) - - await user.ensure_started(even_if_no_session=True) - - if "phone" in data: - return await self.post_login_phone(user, data["phone"]) - elif "token" in data: - return await self.post_login_token(user, data["token"]) - elif "code" in data: - resp = await self.post_login_code(user, data["code"], - password_in_data="password" in data) - if resp or "password" not in data: - return resp - elif "password" not in data: - return self.render_login(error="No data given.", status=400) - - if "password" in data: - return await self.post_login_password(user, data["password"]) - return self.render_login(error="This should never happen.", status=500) diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index da62fad8..ea0b92e3 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -14,6 +14,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import Dict import logging import asyncio import re @@ -38,23 +39,24 @@ class User(AbstractUser): def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0, is_bot=False, db_portals=None, db_instance=None): super().__init__() - self.mxid = mxid - self.tgid = tgid - self.is_bot = is_bot - self.username = username + self.mxid = mxid # type: str + self.tgid = tgid # type: int + self.is_bot = is_bot # type: bool + self.username = username # type: str self.contacts = [] self.saved_contacts = saved_contacts self.db_contacts = db_contacts - self.portals = {} + self.portals = {} # type: Dict[str, po.Portal] self.db_portals = db_portals self._db_instance = db_instance - self.command_status = None + self.command_status = None # type: dict (self.relaybot_whitelisted, self.whitelisted, self.puppet_whitelisted, - self.is_admin) = config.get_permissions(self.mxid) + self.is_admin, + self.permissions) = config.get_permissions(self.mxid) self.by_mxid[mxid] = self if tgid: @@ -255,7 +257,7 @@ class User(AbstractUser): async def sync_dialogs(self, synchronous_create=False): creators = [] - for entity in await self._get_dialogs(limit=30): + for entity in await self.get_dialogs(limit=30): portal = po.Portal.get_by_entity(entity) self.portals[portal.tgid_full] = portal creators.append( @@ -308,6 +310,9 @@ class User(AbstractUser): @classmethod def get_by_mxid(cls, mxid, create=True): + if not mxid: + raise ValueError("Matrix ID can't be empty") + try: return cls.by_mxid[mxid] except KeyError: diff --git a/mautrix_telegram/util/__init__.py b/mautrix_telegram/util/__init__.py index 7d431396..99cdee2a 100644 --- a/mautrix_telegram/util/__init__.py +++ b/mautrix_telegram/util/__init__.py @@ -1,2 +1,3 @@ from .file_transfer import transfer_file_to_matrix, convert_image from .format_duration import format_duration +from .signed_token import sign_token, verify_token diff --git a/mautrix_telegram/util/signed_token.py b/mautrix_telegram/util/signed_token.py new file mode 100644 index 00000000..13281012 --- /dev/null +++ b/mautrix_telegram/util/signed_token.py @@ -0,0 +1,53 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from typing import Optional +import json +import base64 +import hashlib + + +def _get_checksum(key: str, payload: bytes) -> str: + hasher = hashlib.sha256() + hasher.update(payload) + hasher.update(key.encode("utf-8")) + checksum = hasher.hexdigest() + return checksum + + +def sign_token(key: str, payload: dict) -> str: + payload = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")) + checksum = _get_checksum(key, payload) + return f"{checksum}:{payload.decode('utf-8')}" + + +def verify_token(key: str, data: str) -> Optional[dict]: + if not data: + return None + + try: + checksum, payload = data.split(":", 1) + except ValueError: + return None + + if checksum != _get_checksum(key, payload.encode("utf-8")): + return None + + payload = base64.urlsafe_b64decode(payload).decode("utf-8") + try: + return json.loads(payload) + except json.JSONDecodeError: + return None diff --git a/mautrix_telegram/web/__init__.py b/mautrix_telegram/web/__init__.py new file mode 100644 index 00000000..002510e8 --- /dev/null +++ b/mautrix_telegram/web/__init__.py @@ -0,0 +1,2 @@ +from .provisioning import ProvisioningAPI +from .public import PublicBridgeWebsite diff --git a/mautrix_telegram/web/common/__init__.py b/mautrix_telegram/web/common/__init__.py new file mode 100644 index 00000000..ccb0d922 --- /dev/null +++ b/mautrix_telegram/web/common/__init__.py @@ -0,0 +1 @@ +from .auth_api import AuthAPI diff --git a/mautrix_telegram/web/common/auth_api.py b/mautrix_telegram/web/common/auth_api.py new file mode 100644 index 00000000..70b66136 --- /dev/null +++ b/mautrix_telegram/web/common/auth_api.py @@ -0,0 +1,150 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from abc import abstractmethod +import abc +import asyncio +import logging + +from telethon.errors import * + +from ...commands.auth import enter_password +from ...util import format_duration + + +class AuthAPI(abc.ABC): + log = logging.getLogger("mau.web.auth") + + def __init__(self, loop): + self.loop = loop # type: asyncio.AbstractEventLoop + + @abstractmethod + def get_login_response(self, status=200, state="", username="", mxid="", message="", error="", + errcode=""): + raise NotImplementedError() + + async def post_login_phone(self, user, phone): + try: + await user.client.sign_in(phone or "+123") + return self.get_login_response(mxid=user.mxid, state="code", status=200, + message="Code requested successfully.") + except PhoneNumberInvalidError: + return self.get_login_response(mxid=user.mxid, state="request", status=400, + errcode="phone_number_invalid", + error="Invalid phone number.") + except PhoneNumberBannedError: + return self.get_login_response(mxid=user.mxid, state="request", status=403, + errcode="phone_number_banned", + error="Your phone number is banned from Telegram.") + except PhoneNumberAppSignupForbiddenError: + return self.get_login_response(mxid=user.mxid, state="request", status=403, + errcode="phone_number_app_signup_forbidden", + error="You have disabled 3rd party apps on your account.") + except PhoneNumberUnoccupiedError: + return self.get_login_response(mxid=user.mxid, state="request", status=404, + errcode="phone_number_unoccupied", + error="That phone number has not been registered.") + except PhoneNumberFloodError: + return self.get_login_response( + mxid=user.mxid, state="request", status=429, errcode="phone_number_flood", + error="Your phone number has been temporarily blocked for flooding. " + "The ban is usually applied for around a day.") + except FloodWaitError as e: + return self.get_login_response( + mxid=user.mxid, state="request", status=429, errcode="flood_wait", + error="Your phone number has been temporarily blocked for flooding. " + f"Please wait for {format_duration(e.seconds)} before trying again.") + except Exception: + self.log.exception("Error requesting phone code") + return self.get_login_response(mxid=user.mxid, state="request", status=500, + errcode="unknown_error", + error="Internal server error while requesting code.") + + async def post_login_token(self, user, token): + try: + user_info = await user.client.sign_in(bot_token=token) + asyncio.ensure_future(user.post_login(user_info), loop=self.loop) + if user.command_status and user.command_status["action"] == "Login": + user.command_status = None + return self.get_login_response(mxid=user.mxid, state="logged-in", status=200, + username=user_info.username) + except AccessTokenInvalidError: + return self.get_login_response(mxid=user.mxid, state="token", status=401, + errcode="bot_token_invalid", + error="Bot token invalid.") + except AccessTokenExpiredError: + return self.get_login_response(mxid=user.mxid, state="token", status=403, + errcode="bot_token_expired", + error="Bot token expired.") + except Exception: + self.log.exception("Error sending bot token") + return self.get_login_response(mxid=user.mxid, state="token", status=500, + error="Internal server error while sending token.") + + async def post_login_code(self, user, code, password_in_data): + try: + user_info = await user.client.sign_in(code=code) + asyncio.ensure_future(user.post_login(user_info), loop=self.loop) + if user.command_status and user.command_status["action"] == "Login": + user.command_status = None + return self.get_login_response(mxid=user.mxid, state="logged-in", status=200, + username=user_info.username) + except PhoneCodeInvalidError: + return self.get_login_response(mxid=user.mxid, state="code", status=401, + errcode="phone_code_invalid", + error="Incorrect phone code.") + except PhoneCodeExpiredError: + return self.get_login_response(mxid=user.mxid, state="code", status=403, + errcode="phone_code_expired", + error="Phone code expired.") + except SessionPasswordNeededError: + if not password_in_data: + if user.command_status and user.command_status["action"] == "Login": + user.command_status = { + "next": enter_password, + "action": "Login (password entry)", + } + return self.get_login_response( + mxid=user.mxid, state="password", status=202, + message="Code accepted, but you have 2-factor authentication is enabled.") + return None + except Exception: + self.log.exception("Error sending phone code") + return self.get_login_response(mxid=user.mxid, state="code", status=500, + errcode="unknown_error", + error="Internal server error while sending code.") + + async def post_login_password(self, user, password): + try: + user_info = await user.client.sign_in(password=password) + asyncio.ensure_future(user.post_login(user_info), loop=self.loop) + if user.command_status and user.command_status["action"] == "Login (password entry)": + user.command_status = None + return self.get_login_response(mxid=user.mxid, state="logged-in", status=200, + username=user_info.username) + except PasswordEmptyError: + return self.get_login_response(mxid=user.mxid, state="password", status=400, + errcode="password_empty", + error="Empty password.") + except PasswordHashInvalidError: + return self.get_login_response(mxid=user.mxid, state="password", status=401, + errcode="password_invalid", + error="Incorrect password.") + except Exception: + self.log.exception("Error sending password") + return self.get_login_response(mxid=user.mxid, state="password", status=500, + errcode="unknown_error", + error="Internal server error while sending password.") diff --git a/mautrix_telegram/web/provisioning/__init__.py b/mautrix_telegram/web/provisioning/__init__.py new file mode 100644 index 00000000..63bb208d --- /dev/null +++ b/mautrix_telegram/web/provisioning/__init__.py @@ -0,0 +1,381 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from aiohttp import web +from typing import Tuple, Optional, Callable, Awaitable +import asyncio +import logging +import json + +from telethon.utils import get_peer_id, resolve_id +from mautrix_appservice import AppService, MatrixRequestError, IntentError + +from ...user import User +from ...portal import Portal +from ...commands.portal import user_has_power_level, get_initial_state +from ...config import Config +from ..common import AuthAPI + + +class ProvisioningAPI(AuthAPI): + log = logging.getLogger("mau.web.provisioning") + + def __init__(self, config: Config, az: AppService, loop: asyncio.AbstractEventLoop): + super().__init__(loop) + self.secret = config["appservice.provisioning.shared_secret"] + self.az = az + + self.app = web.Application(loop=loop, middlewares=[self.error_middleware]) + + portal_prefix = "/portal/{mxid:![^/]+}" + self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid) + self.app.router.add_route("GET", "/portal/{tgid:-[0-9]+}", self.get_portal_by_tgid) + self.app.router.add_route("POST", portal_prefix + "/connect/{chat_id:[0-9]+}", + self.connect_chat) + self.app.router.add_route("POST", f"{portal_prefix}/create", self.create_chat) + self.app.router.add_route("POST", f"{portal_prefix}/disconnect", self.disconnect_chat) + + user_prefix = "/user/{mxid:@[^:]*:[^/]+}" + self.app.router.add_route("GET", f"{user_prefix}", self.get_user_info) + self.app.router.add_route("GET", f"{user_prefix}/chats", self.get_chats) + + self.app.router.add_route("POST", f"{user_prefix}/logout", self.logout) + self.app.router.add_route("POST", f"{user_prefix}/login/bot_token", self.send_bot_token) + self.app.router.add_route("POST", f"{user_prefix}/login/request_code", self.request_code) + self.app.router.add_route("POST", f"{user_prefix}/login/send_code", self.send_code) + self.app.router.add_route("POST", f"{user_prefix}/login/send_password", self.send_password) + + async def get_portal_by_mxid(self, request: web.Request) -> web.Response: + err = self.check_authorization(request) + if err is not None: + return err + + mxid = request.match_info["mxid"] + portal = Portal.get_by_mxid(mxid) + if not portal: + return self.get_error_response(404, "portal_not_found", + "Portal with given Matrix ID not found.") + return web.json_response({ + "mxid": portal.mxid, + "chat_id": get_peer_id(portal.peer), + "peer_type": portal.peer_type, + "title": portal.title, + "about": portal.about, + "username": portal.username, + "megagroup": portal.megagroup, + }) + + async def get_portal_by_tgid(self, request: web.Request) -> web.Response: + err = self.check_authorization(request) + if err is not None: + return err + + try: + tgid, _ = resolve_id(int(request.match_info["tgid"])) + except ValueError: + return self.get_error_response(400, "tgid_invalid", + "Given chat ID is not valid.") + portal = Portal.get_by_tgid(tgid) + if not portal: + return self.get_error_response(404, "portal_not_found", + "Portal to given Telegram chat not found.") + return web.json_response({ + "mxid": portal.mxid, + "chat_id": get_peer_id(portal.peer), + "peer_type": portal.peer_type, + "title": portal.title, + "about": portal.about, + "username": portal.username, + "megagroup": portal.megagroup, + }) + + async def connect_chat(self, request: web.Request) -> web.Response: + err = self.check_authorization(request) + if err is not None: + return err + + return self.get_error_response(501, "not_implemented", + "Connecting existing Matrix rooms to existing Telegram " + "chats via the provisioning API is not yet implemented.") + + async def create_chat(self, request: web.Request) -> web.Response: + err = self.check_authorization(request) + if err is not None: + return err + + data = await self.get_data(request) + if not data: + return self.get_error_response(400, "json_invalid", "Invalid JSON.") + + room_id = request.match_info["mxid"] + if Portal.get_by_mxid(room_id): + return self.get_error_response(409, "room_already_bridged", + "Room is already bridged to another Telegram chat.") + + user, err = await self.get_user(request.query.get("user_id", None), expect_logged_in=None, + require_puppeting=False) + if err is not None: + return err + elif not await user.is_logged_in() or user.is_bot: + return self.get_error_response(403, "not_logged_in_real_account", + "You are not logged in with a real account.") + elif not await user_has_power_level(room_id, self.az.intent, user, "bridge"): + return self.get_error_response(403, "not_enough_permissions", + "You do not have the permissions to bridge that room.") + + try: + title, about, _ = await get_initial_state(self.az.intent, room_id) + except (MatrixRequestError, IntentError): + return self.get_error_response(403, "bot_not_in_room", + "The bridge bot is not in the given room.") + + about = data.get("about", about) + + title = data.get("title", title) + if len(title) == 0: + return self.get_error_response(400, "body_value_invalid", "Title can not be empty.") + + type = data.get("type", "") + if type not in ("group", "chat", "supergroup", "channel"): + return self.get_error_response(400, "body_value_invalid", + "Given chat type is not valid.") + + supergroup = type == "supergroup" + type = { + "supergroup": "channel", + "channel": "channel", + "chat": "chat", + "group": "chat", + }[type] + + portal = Portal(tgid=None, mxid=room_id, title=title, about=about, peer_type=type) + try: + await portal.create_telegram_chat(user, supergroup=supergroup) + except ValueError as e: + portal.delete() + return self.get_error_response(500, "unknown_error", e.args[0]) + + return web.json_response({ + "chat_id": portal.tgid, + }) + + async def disconnect_chat(self, request: web.Request) -> web.Response: + err = self.check_authorization(request) + if err is not None: + return err + + portal = Portal.get_by_mxid(request.match_info["mxid"]) + if not portal or not portal.tgid: + return self.get_error_response(404, "portal_not_found", + "Room is not a portal.") + + user, err = await self.get_user(request.query.get("user_id", None), expect_logged_in=None, + require_puppeting=False, require_user=False) + if err is not None: + return err + elif user and not await user_has_power_level(portal.mxid, self.az.intent, user, "unbridge"): + return self.get_error_response(403, "not_enough_permissions", + "You do not have the permissions to unbridge that room.") + + delete = request.query.get("delete", "").lower() in ("true", "t", "1", "yes", "y") + sync = request.query.get("delete", "").lower() in ("true", "t", "1", "yes", "y") + + coro = portal.cleanup_and_delete() if delete else portal.unbridge() + if sync: + try: + await coro + except Exception: + self.log.exception("Failed to disconnect chat") + return self.get_error_response(500, "exception", "Failed to disconnect chat") + else: + asyncio.ensure_future(coro, loop=self.loop) + return web.json_response({}, status=200 if sync else 202) + + async def get_user_info(self, request: web.Request) -> web.Response: + data, user, err = await self.get_user_request_info(request, expect_logged_in=None, + require_puppeting=False) + if err is not None: + return err + + user_data = None + if await user.is_logged_in(): + me = await user.client.get_me() + await user.update_info(me) + user_data = { + "id": user.tgid, + "username": user.username, + "first_name": me.first_name, + "last_name": me.last_name, + "phone": me.phone, + "is_bot": user.is_bot, + } + return web.json_response({ + "telegram": user_data, + "mxid": user.mxid, + "permissions": user.permissions, + }) + + async def get_chats(self, request: web.Request) -> web.Response: + data, user, err = await self.get_user_request_info(request, expect_logged_in=True) + if err is not None: + return err + + if not user.is_bot: + chats = await user.get_dialogs() + return web.json_response([{ + "id": get_peer_id(chat), + "title": chat.title, + } for chat in chats]) + else: + return web.json_response([{ + "id": get_peer_id(chat.peer), + "title": chat.title, + } for chat in user.portals.values() if chat.tgid]) + + async def send_bot_token(self, request: web.Request) -> web.Response: + data, user, err = await self.get_user_request_info(request) + if err is not None: + return err + return await self.post_login_token(user, data.get("token", "")) + + async def request_code(self, request: web.Request) -> web.Response: + data, user, err = await self.get_user_request_info(request) + if err is not None: + return err + return await self.post_login_phone(user, data.get("phone", "")) + + async def send_code(self, request: web.Request) -> web.Response: + data, user, err = await self.get_user_request_info(request) + if err is not None: + return err + return await self.post_login_code(user, data.get("code", 0), password_in_data=False) + + async def send_password(self, request: web.Request) -> web.Response: + data, user, err = await self.get_user_request_info(request) + if err is not None: + return err + return await self.post_login_password(user, data.get("password", "")) + + async def logout(self, request: web.Request) -> web.Response: + _, user, err = await self.get_user_request_info(request, expect_logged_in=True, + require_puppeting=False, + want_data=False) + if err is not None: + return err + await user.log_out() + + @staticmethod + async def error_middleware(_, handler) -> Callable[[web.Request], Awaitable[web.Response]]: + async def middleware_handler(request: web.Request) -> web.Response: + try: + return await handler(request) + except web.HTTPException as ex: + return web.json_response({ + "error": f"Unhandled HTTP {ex.status}", + "errcode": f"unhandled_http_{ex.status}", + }, status=ex.status) + + return middleware_handler + + @staticmethod + def get_error_response(status=200, errcode="", error="") -> web.Response: + return web.json_response({ + "error": error, + "errcode": errcode, + }, status=status) + + def get_login_response(self, status=200, state="", username="", mxid="", message="", error="", + errcode="") -> web.Response: + if username: + resp = { + "state": "logged-in", + "username": username, + } + elif message: + resp = { + "state": state, + "message": message, + } + else: + resp = { + "error": error, + "errcode": errcode, + } + if state: + resp["state"] = state + return web.json_response(resp, status=status) + + def check_authorization(self, request: web.Request) -> Optional[web.Response]: + auth = request.headers.get("Authorization", "") + if auth != f"Bearer {self.secret}": + return self.get_error_response(error="Shared secret is not valid.", + errcode="shared_secret_invalid", + status=401) + return None + + @staticmethod + async def get_data(request: web.Request) -> Optional[dict]: + try: + return await request.json() + except json.JSONDecodeError: + return None + + async def get_user(self, mxid: str, expect_logged_in: Optional[bool] = False, + require_puppeting: bool = True, require_user: bool = True + ) -> Tuple[Optional[User], Optional[web.Response]]: + if not mxid: + if not require_user: + return None, None + return None, self.get_login_response(error="User ID not given.", + errcode="mxid_empty", status=400) + + user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True) + if require_puppeting and not user.puppet_whitelisted: + return user, self.get_login_response(error="You are not whitelisted.", + errcode="mxid_not_whitelisted", status=403) + if expect_logged_in is not None: + logged_in = await user.is_logged_in() + if not expect_logged_in and logged_in: + return user, self.get_login_response(username=user.username, status=409, + error="You are already logged in.", + errcode="already_logged_in") + elif expect_logged_in and not logged_in: + return user, self.get_login_response(status=403, error="You are not logged in.", + errcode="not_logged_in") + return user, None + + async def get_user_request_info(self, request: web.Request, + expect_logged_in: Optional[bool] = False, + require_puppeting: bool = False, + want_data: bool = True, + ) -> (Tuple[Optional[dict], + Optional[User], + Optional[web.Response]]): + err = self.check_authorization(request) + if err is not None: + return err + + data = None + if want_data and (request.method == "POST" or request.method == "PUT"): + data = await self.get_data(request) + if not data: + return None, None, self.get_login_response(error="Invalid JSON.", + errcode="json_invalid", status=400) + + mxid = request.match_info["mxid"] + user, err = await self.get_user(mxid, expect_logged_in, require_puppeting) + + return data, user, err diff --git a/mautrix_telegram/web/provisioning/spec.yaml b/mautrix_telegram/web/provisioning/spec.yaml new file mode 100644 index 00000000..4c1c44c4 --- /dev/null +++ b/mautrix_telegram/web/provisioning/spec.yaml @@ -0,0 +1,844 @@ +swagger: "2.0" + +info: + title: Mautrix-Telegram provisioning + version: 0.3.0 + description: The provisioning API for Mautrix-Telegram, the Matrix-Telegram puppeting/relaybot bridge. + license: + name: AGPLv3 + url: https://github.com/tulir/mautrix-telegram/blob/master/LICENSE + +externalDocs: + description: Provisioning API wiki page on GitHub + url: https://github.com/tulir/mautrix-telegram/wiki/Provisioning-API + +basePath: /_matrix/provision/v1 + +schemes: [https] +consumes: [application/json] +produces: [application/json] + +tags: +- name: User info +- name: Authentication +- name: Bridging + +paths: + /portal/{room_id}: + get: + operationId: get_portal + summary: Get the bridging status and info of the connected Telegram chat + tags: [Bridging] + responses: + 200: + description: Room is bridged + schema: + $ref: "#/definitions/PortalInfo" + 400: + $ref: "#/responses/BadRequest" + 404: + description: Unknown portal + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - portal_not_found + error: + $ref: "#/definitions/HumanReadableError" + parameters: + - name: room_id + in: path + description: The Matrix ID of the room whose bridging status to get + required: true + type: string + pattern: "![^/]+" + /portal/{chat_id}: + get: + operationId: get_portal_by_tgid + summary: Get the bridging status and info of the connected Telegram chat + tags: [Bridging] + responses: + 200: + description: Chat is bridged + schema: + $ref: "#/definitions/PortalInfo" + 400: + description: Invalid Telegram chat ID + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - tgid_invalid + error: + $ref: "#/definitions/HumanReadableError" + 404: + description: Unknown portal + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - portal_not_found + error: + $ref: "#/definitions/HumanReadableError" + parameters: + - name: chat_id + in: path + description: The Matrix ID of the room whose bridging status to get + required: true + type: integer + pattern: "-[0-9]+" + /portal/{room_id}/connect/{chat_id}: + post: + operationId: connect_portal + summary: Connect an existing Telegram chat to the given room + tags: [Bridging] + parameters: + - name: room_id + in: path + description: The Matrix ID of the room to which the Telegram chat should be connected + required: true + type: string + - name: chat_id + in: path + description: The ID of the Telegram chat to connect + required: true + type: integer + pattern: "-[0-9]+" + - name: force + in: query + description: Set to force bridging by unbridging or deleting existing portal rooms. + required: false + type: string + enum: + - delete + - unbridge + - name: user_id + in: query + description: Optional Matrix user ID to check if the user has permissions to do the bridging. + required: false + type: string + responses: + 400: + $ref: "#/responses/BadRequest" + 401: + $ref: "#/responses/PermissionError" + 409: + description: Matrix room or Telegram chat is already bridged + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + example: _already_bridged + enum: + - room_already_bridged + - chat_already_bridged + error: + $ref: "#/definitions/HumanReadableError" + /portal/{room_id}/create: + post: + operationId: create_portal + summary: Create a new Telegram chat for the given room + tags: [Bridging] + responses: + 200: + description: Telegram chat created + schema: + type: object + properties: + chat_id: + type: integer + 400: + $ref: "#/responses/BadRequest" + 401: + $ref: "#/responses/PermissionError" + 403: + description: "Given user isn't logged in with a real account or doesn't have permission to bridge the room, or the bridge bot is not in the room" + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - not_logged_in_real_account + - not_enough_permissions + - bot_not_in_room + error: + $ref: "#/definitions/HumanReadableError" + 409: + description: Room is already bridged + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - room_already_bridged + error: + $ref: "#/definitions/HumanReadableError" + parameters: + - name: room_id + in: path + description: The Matrix ID of the room whose bridging status to get + required: true + type: string + - name: body + in: body + required: true + schema: + type: object + required: [type] + properties: + type: + description: The type of chat to create + type: string + example: supergroup + enum: + - chat + - supergroup + - channel + title: + description: Title for the new chat + type: string + example: Mautrix-Telegram Bridge + about: + description: About text for the new chat + type: string + example: Discussion about mautrix-telegram + - name: user_id + in: query + description: Matrix user to create the chat as. + required: true + type: string + /portal/{room_id}/disconnect: + post: + operationId: disconnect_portal + summary: Disconnect the Telegram chat from the room + tags: [Bridging] + responses: + 202: + description: Room unbridging initiated + 400: + $ref: "#/responses/BadRequest" + 401: + $ref: "#/responses/PermissionError" + 404: + description: Unknown portal + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - portal_not_found + error: + $ref: "#/definitions/HumanReadableError" + parameters: + - name: room_id + in: path + description: The Matrix ID of the room whose bridging status to get + required: true + type: string + - name: user_id + in: query + description: Optional Matrix user ID to check if the user has permissions to do the bridging. + required: false + type: string + - name: delete + in: query + description: Whether or not to delete the room completely (kick all users instead of just Telegram puppets) + required: false + type: boolean + default: false + - name: sync + in: query + description: Whether or not to wait for the unbridging to be completed before responding. **Could cause timeouts in large rooms** + required: false + type: boolean + default: false + + /user/{user_id}: + get: + operationId: get_me + summary: Get the info of the Telegram user the given Matrix user is logged in as + tags: [User info] + responses: + 200: + description: User found + schema: + $ref: "#/definitions/UserInfo" + 400: + $ref: "#/responses/BadRequest" + 403: + $ref: "#/responses/NotWhitelistedError" + 500: + $ref: "#/responses/UnknownError" + parameters: + - name: user_id + in: path + description: The Matrix ID of the user who to log in as + required: true + type: string + /user/{user_id}/chats: + get: + operationId: get_chats + summary: Get the list of Telegram chats the given Matrix user has access to + tags: [User info] + responses: + 200: + description: User is logged in + schema: + $ref: "#/definitions/UserChats" + 400: + $ref: "#/responses/BadRequest" + 403: + description: User is not logged in or not whitelisted + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - not_logged_in + - mxid_not_whitelisted + error: + $ref: "#/definitions/HumanReadableError" + 500: + $ref: "#/responses/UnknownError" + parameters: + - name: user_id + in: path + description: The Matrix ID of the user who to log in as + required: true + type: string + + /user/{user_id}/login/bot_token: + post: + operationId: post_bot_token + summary: Log in with a bot token + tags: [Authentication] + responses: + 200: + description: Login successful + schema: + $ref: "#/definitions/AuthSuccess" + 400: + $ref: "#/responses/BadRequest" + 401: + description: Invalid or expired bot token or invalid shared secret + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + example: bot_token_ + enum: + - bot_token_invalid + - bot_token_expired + - shared_secret_invalid + error: + $ref: "#/definitions/HumanReadableError" + 403: + $ref: "#/responses/NotWhitelistedError" + 409: + $ref: "#/responses/AlreadyLoggedInError" + 500: + $ref: "#/responses/UnknownError" + parameters: + - name: user_id + in: path + description: The Matrix ID of the user who to log in as + required: true + type: string + - name: body + in: body + required: true + schema: + type: object + properties: + token: + type: string + description: The access token of the bot to log in as + example: "297900271:IXjeGEcAN61zHnjPgkWnYWyvVp9K4ulHBEv" + /user/{user_id}/login/request_code: + post: + operationId: post_login_phone + summary: Request a phone code from Telegram + tags: [Authentication] + responses: + 200: + description: Code requested successfully + schema: + $ref: "#/definitions/AuthSuccess" + 400: + description: Invalid phone number or JSON + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + example: machine_readable_error + enum: + - phone_number_invalid + - json_invalid + error: + $ref: "#/definitions/HumanReadableError" + 401: + description: Invalid shared secret + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - shared_secret_invalid + error: + $ref: "#/definitions/HumanReadableError" + 403: + description: Matrix ID is not whitelisted or phone number is banned or has forbidden 3rd party apps + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + example: machine_readable_error + enum: + - mxid_not_whitelisted + - phone_number_banned + - phone_number_app_signup_forbidden + error: + $ref: "#/definitions/HumanReadableError" + 404: + description: Unregistered phone number + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - phone_number_unoccupied + error: + $ref: "#/definitions/HumanReadableError" + 409: + $ref: "#/responses/AlreadyLoggedInError" + 429: + description: Phone number has been temporarily blocked for flooding + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - flood_wait + - phone_number_flood + error: + $ref: "#/definitions/HumanReadableError" + 500: + $ref: "#/responses/UnknownError" + parameters: + - name: user_id + in: path + description: The Matrix ID of the user who to log in as + required: true + type: string + - name: body + in: body + required: true + schema: + type: object + properties: + phone: + type: string + description: The phone number to log in as. + example: "+123456789" + /user/{user_id}/login/send_code: + post: + operationId: post_login_code + summary: Send the login code + tags: [Authentication] + responses: + 200: + description: Login successful + schema: + $ref: "#/definitions/AuthSuccess" + 202: + description: Correct code, but two-factor authentication is enabled + schema: + $ref: "#/definitions/AuthSuccess" + 400: + $ref: "#/responses/BadRequest" + 401: + description: Invalid phone code or shared secret + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - phone_code_invalid + - shared_secret_invalid + error: + $ref: "#/definitions/HumanReadableError" + 403: + description: Matrix ID not whitelisted or phone code expired + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + example: machine_readable_error + enum: + - mxid_not_whitelisted + - phone_code_expired + error: + $ref: "#/definitions/HumanReadableError" + 409: + $ref: "#/responses/AlreadyLoggedInError" + 500: + $ref: "#/responses/UnknownError" + parameters: + - name: user_id + in: path + description: The Matrix ID of the user who to log in as + required: true + type: string + - name: body + in: body + required: true + schema: + type: object + properties: + code: + type: integer + description: The phone code from Telegram. + format: int32 + example: 123456 + /user/{user_id}/login/send_password: + post: + operationId: post_login_password + summary: Send the two-factor auth password + tags: [Authentication] + responses: + 200: + description: Login successful + schema: + $ref: "#/definitions/AuthSuccess" + 400: + description: Missing password or invalid JSON + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + example: _empty + enum: + - password_empty + - json_invalid + error: + $ref: "#/definitions/HumanReadableError" + 401: + description: Incorrect password or invalid shared secret + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - password_invalid + - shared_secret_invalid + error: + $ref: "#/definitions/HumanReadableError" + 403: + $ref: "#/responses/NotWhitelistedError" + 409: + $ref: "#/responses/AlreadyLoggedInError" + 500: + $ref: "#/responses/UnknownError" + parameters: + - name: user_id + in: path + description: The Matrix ID of the user who to log in as + required: true + type: string + - name: body + in: body + required: true + schema: + type: object + properties: + password: + type: string + description: The two-factor auth password + format: password + example: hunter2 + /user/{user_id}/logout: + post: + operationId: logout + summary: Log out + tags: [Authentication] + responses: + 200: + description: Logout successful + 403: + description: User was not logged in + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - not_logged_in + error: + $ref: "#/definitions/HumanReadableError" + 500: + $ref: "#/responses/UnknownError" + parameters: + - name: user_id + in: path + description: The Matrix ID of the user who to log out as + required: true + type: string + +responses: + NotWhitelistedError: + description: Matrix ID not whitelisted for puppeting + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - mxid_not_whitelisted + error: + $ref: "#/definitions/HumanReadableError" + AlreadyLoggedInError: + description: The Matrix user is already logged in + schema: + type: object + properties: + state: + type: string + enum: + - logged-in + username: + type: string + description: The Telegram username the user is logged in as. + BadRequest: + description: Invalid JSON. + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - json_invalid + - mxid_empty + - body_value_missing + - body_value_invalid + error: + $ref: "#/definitions/HumanReadableError" + UnknownError: + description: Unknown error + schema: + type: object + title: UnknownError + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + enum: + - unknown_error + - unhandled_error + error: + type: string + title: Error + description: A human-readable description of the error + example: Internal server error while . + PermissionError: + description: The given Matrix user doesn't have the permissions to do that. + schema: + type: object + title: Error + properties: + errcode: + type: string + title: Error code + description: A machine-readable error code + example: not_enough_permissions + enum: + - not_enough_permissions + error: + $ref: "#/definitions/HumanReadableError" + +definitions: + UserInfo: + type: object + properties: + mxid: + type: string + example: "@usern:example.com" + permissions: + type: string + example: user + enum: + - none + - relaybot + - user + - full + - admin + telegram: + type: object + properties: + id: + type: integer + example: 123456789 + username: + type: string + example: username + first_name: + type: string + example: Usern + last_name: + type: string + example: A. + phone: + type: string + example: +123456789 + is_bot: + type: boolean + example: false + UserChats: + type: array + items: + type: object + properties: + id: + type: integer + example: -123456789 + description: A bot API style chat ID. + title: + type: string + + PortalInfo: + type: object + properties: + mxid: + type: string + example: "!foo:example.com" + chat_id: + type: integer + example: -100123456789 + peer_type: + type: string + enum: + - user + - chat + - channel + megagroup: + type: boolean + username: + type: string + title: + type: string + about: + type: string + + AuthSuccess: + type: object + properties: + state: + type: string + description: The state/next step after the successful operation. + enum: + - code + - request + - password + - token + - logged-in + username: + type: string + description: The Telegram username the user is logged in as. Only applicable if state=logged-in + + HumanReadableError: + type: string + description: A human-readable description of the error + example: A human-readable description of the error + +security: + - Bearer: [] +securityDefinitions: + Bearer: + description: Required authentication for all endpoints + name: Authorization + in: header + type: apiKey diff --git a/mautrix_telegram/web/public/__init__.py b/mautrix_telegram/web/public/__init__.py new file mode 100644 index 00000000..fb5f6de7 --- /dev/null +++ b/mautrix_telegram/web/public/__init__.py @@ -0,0 +1,114 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from aiohttp import web +from mako.template import Template +import pkg_resources +import logging +import random +import string +import time + +from ...util import sign_token, verify_token +from ...user import User +from ..common import AuthAPI + + +class PublicBridgeWebsite(AuthAPI): + log = logging.getLogger("mau.web.public") + + def __init__(self, loop): + super().__init__(loop) + self.secret_key = "".join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) + + self.login = Template( + pkg_resources.resource_string("mautrix_telegram", "web/public/login.html.mako")) + + self.app = web.Application(loop=loop) + self.app.router.add_route("GET", "/login", self.get_login) + self.app.router.add_route("POST", "/login", self.post_login) + self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_telegram", + "web/public/")) + + def make_token(self, mxid, expires_in=900): + return sign_token(self.secret_key, { + "mxid": mxid, + "expiry": int(time.time()) + expires_in, + }) + + def verify_token(self, token): + token = verify_token(self.secret_key, token) + if token and token.get("expiry", 0) > int(time.time()): + return token.get("mxid", None) + return None + + async def get_login(self, request): + state = "bot_token" if request.rel_url.query.get("mode", "") == "bot" else "request" + + mxid = self.verify_token(request.rel_url.query.get("token", None)) + if not mxid: + return self.get_login_response(status=401, state="invalid-token") + user = User.get_by_mxid(mxid, create=False) if mxid else None + + if not user: + return self.get_login_response(mxid=mxid, state=state) + elif not user.puppet_whitelisted: + return self.get_login_response(mxid=user.mxid, error="You are not whitelisted.", + status=403) + await user.ensure_started() + if not await user.is_logged_in(): + return self.get_login_response(mxid=user.mxid, state=state) + + return self.get_login_response(mxid=user.mxid, username=user.username) + + def get_login_response(self, status=200, state="", username="", mxid="", message="", error="", + errcode=""): + return web.Response(status=status, content_type="text/html", + text=self.login.render(username=username, state=state, error=error, + message=message, mxid=mxid)) + + async def post_login(self, request): + mxid = self.verify_token(request.rel_url.query.get("token", None)) + if not mxid: + return self.get_login_response(status=401, state="invalid-token") + + data = await request.post() + + user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True) + if not user.puppet_whitelisted: + return self.get_login_response(mxid=user.mxid, error="You are not whitelisted.", + status=403) + elif await user.is_logged_in(): + return self.get_login_response(mxid=user.mxid, username=user.username) + + await user.ensure_started(even_if_no_session=True) + + if "phone" in data: + return await self.post_login_phone(user, data["phone"]) + elif "bot_token" in data: + return await self.post_login_token(user, data["bot_token"]) + elif "code" in data: + resp = await self.post_login_code(user, data["code"], + password_in_data="password" in data) + if resp or "password" not in data: + return resp + elif "password" not in data: + return self.get_login_response(error="No data given.", status=400) + + if "password" in data: + return await self.post_login_password(user, data["password"]) + return self.get_login_response(error="This should never happen.", status=500) diff --git a/mautrix_telegram/public/favicon.png b/mautrix_telegram/web/public/favicon.png similarity index 100% rename from mautrix_telegram/public/favicon.png rename to mautrix_telegram/web/public/favicon.png diff --git a/mautrix_telegram/public/login.css b/mautrix_telegram/web/public/login.css similarity index 100% rename from mautrix_telegram/public/login.css rename to mautrix_telegram/web/public/login.css diff --git a/mautrix_telegram/public/login.html.mako b/mautrix_telegram/web/public/login.html.mako similarity index 92% rename from mautrix_telegram/public/login.html.mako rename to mautrix_telegram/web/public/login.html.mako index 8c03cbdc..f00b6a69 100644 --- a/mautrix_telegram/public/login.html.mako +++ b/mautrix_telegram/web/public/login.html.mako @@ -76,6 +76,9 @@ along with this program. If not, see . management command first.

% endif + % elif state == "invalid-token": +

Invalid or expired token

+
Please ask the bridge bot for a new login link.
% else:

Log in to Telegram

% if error: @@ -87,8 +90,7 @@ along with this program. If not, see .
- + % if state == "request": @@ -96,9 +98,9 @@ along with this program. If not, see . - % elif state == "token": + % elif state == "bot_token": - + % elif state == "code":