diff --git a/example-config.yaml b/example-config.yaml index 90166e14..ee68b25d 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -14,6 +14,18 @@ appservice: hostname: localhost port: 8080 + # Public part of web server for out-of-Matrix interaction with the bridge. + # Used for things like login if the user wants to make sure the 2FA password isn't stored in + # the HS database. + public: + # Whether or not the public-facing endpoints should be enabled. + enabled: true + # The prefix to use in the public-facing endpoints. + prefix: /public + # The base URL where the public-facing endpoints are available. The prefix is not added + # implicitly. + external: https://example.com/public + # Whether or not to enable debug messages in the console. debug: false @@ -60,18 +72,21 @@ bridge: # Whether or not to use native Matrix replies. At the time of writing, only riot-web supports # replies and the format of them is subject to change. - native_replies: True + native_replies: true # If native replies are disabled, should the custom replies contain a link to the message being # replied to? - link_in_reply: False + link_in_reply: false # Show message editing as a reply to the original message. # If this is false, message edits are not shown at all, as Matrix does not support editing yet. - edits_as_replies: False + edits_as_replies: false # Whether or not Matrix bot messages (type m.notice) should be bridged. - bridge_notices: False + bridge_notices: false # The maximum number of simultaneous Telegram deletions to handle. # A large number of simultaneous redactions could put strain on your homeserver. max_telegram_delete: 10 + # Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix + # login website (see appservice.public config section) + allow_matrix_login: true # The prefix for commands. Only required in non-management rooms. command_prefix: "!tg" diff --git a/mautrix_appservice/appservice.py b/mautrix_appservice/appservice.py index c34e5385..bb340607 100644 --- a/mautrix_appservice/appservice.py +++ b/mautrix_appservice/appservice.py @@ -16,7 +16,6 @@ # along with this program. If not, see . # # Partly based on github.com/Cadair/python-appservice-framework (MIT license) -from functools import partial from contextlib import contextmanager from aiohttp import web import aiohttp diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py index 1b6d9423..2f01e1ac 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -34,6 +34,7 @@ from .user import init as init_user, User from .bot import init as init_bot from .portal import init as init_portal from .puppet import init as init_puppet +from .public import PublicBridgeWebsite from .context import Context log = logging.getLogger("mau") @@ -76,6 +77,7 @@ Base.metadata.bind = db_engine Base.metadata.create_all() loop = asyncio.get_event_loop() + appserv = AppService(config["homeserver.address"], config["homeserver.domain"], config["appservice.as_token"], config["appservice.hs_token"], config["appservice.bot_username"], log="mau.as", loop=loop) @@ -83,6 +85,10 @@ appserv = AppService(config["homeserver.address"], config["homeserver.domain"], context = Context(appserv, db_session, config, loop, None, None) +if config["appservice.public.enabled"]: + public = PublicBridgeWebsite(loop) + appserv.app.add_subapp(config.get("appservice.public.prefix", "/public"), public.app) + with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start: init_db(db_session) init_abstract_user(context) diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index 3b6a9100..dd8c4cd9 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -54,16 +54,58 @@ def register(evt): async def login(evt): if evt.sender.logged_in: return await evt.reply("You are already logged in.") - elif len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp login `") - phone_number = evt.args[0] - await evt.sender.ensure_started(even_if_no_session=True) - await evt.sender.client.sign_in(phone_number) evt.sender.command_status = { - "next": enter_code, + "next": enter_phone, "action": "Login", } - return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.") + if evt.config["appservice.public.enabled"]: + prefix = evt.config["appservice.public.external"] + url = f"{prefix}/login?mxid={evt.sender.mxid}" + if evt.config.get("bridge.allow_matrix_login", True): + return await evt.reply("\n\n".join(( + "This bridge instance allows you to log in inside or outside Matrix.", + "If you would like to log in within Matrix, please send your phone number here.", + f"If you would like to log in outside of Matrix, [click here]({url})."))) + return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n" + f"Please visit [the login page]({url}) to log in.") + return await evt.reply( + "This bridge instance does not allow you to log in outside of Matrix.\n\n" + "Please send your phone number here to start the login process.") + + +@command_handler(needs_auth=False) +async def enter_phone(evt): + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone `") + + phone_number = evt.args[0] + try: + await evt.sender.ensure_started(even_if_no_session=True) + await evt.sender.client.sign_in(phone_number) + evt.sender.command_status = { + "next": enter_code, + "action": "Login", + } + return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.") + except PhoneNumberAppSignupForbiddenError: + return await evt.reply( + "Your phone number does not allow 3rd party apps to sign in.") + except PhoneNumberFloodError: + return await evt.reply( + "Your phone number has been temporarily blocked for flooding. " + "The ban is usually applied for around a day.") + except PhoneNumberBannedError: + return await evt.reply("Your phone number has been banned from Telegram.") + except PhoneNumberUnoccupiedError: + return await evt.reply("That phone number has not been registered. " + "Please register with `$cmdprefix+sp register `.") + except Exception: + evt.log.exception("Error requesting phone code") + return await evt.reply("Unhandled exception while requesting code. " + "Check console for more details.") + finally: + if evt.sender.command_status["next"] == enter_phone: + evt.sender.command_status = None @command_handler(needs_auth=False) @@ -77,33 +119,21 @@ async def enter_code(evt): asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop) evt.sender.command_status = None return await evt.reply(f"Successfully logged in as @{user.username}") - except PhoneNumberUnoccupiedError: - return await evt.reply("That phone number has not been registered." - "Please register with `$cmdprefix+sp register `.") except PhoneCodeExpiredError: return await evt.reply( "Phone code expired. Try again with `$cmdprefix+sp login `.") except PhoneCodeInvalidError: return await evt.reply("Invalid phone code.") - except PhoneNumberAppSignupForbiddenError: - return await evt.reply( - "Your phone number does not allow 3rd party apps to sign in.") - except PhoneNumberFloodError: - return await evt.reply( - "Your phone number has been temporarily blocked for flooding. " - "The block is usually applied for around a day.") - except PhoneNumberBannedError: - return await evt.reply("Your phone number has been banned from Telegram.") except SessionPasswordNeededError: evt.sender.command_status = { "next": enter_password, "action": "Login (password entry)", } - return await evt.reply("Your account has two-factor authentication." + return await evt.reply("Your account has two-factor authentication. " "Please send your password here.") except Exception: evt.log.exception("Error sending phone code") - return await evt.reply("Unhandled exception while sending code." + return await evt.reply("Unhandled exception while sending code. " "Check console for more details.") diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py index 94c6a356..43655129 100644 --- a/mautrix_telegram/commands/handler.py +++ b/mautrix_telegram/commands/handler.py @@ -19,6 +19,8 @@ import logging from telethon.errors import FloodWaitError +from ..util import format_duration + command_handlers = {} @@ -67,24 +69,6 @@ class CommandEvent: return self.az.intent.send_notice(self.room_id, message, html=html) -def format_duration(seconds): - def pluralize(count, singular): return singular if count == 1 else singular + "s" - - def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else "" - - minutes, seconds = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - days, hours = divmod(hours, 24) - parts = [a for a in [ - include(days, "day"), - include(hours, "hour"), - include(minutes, "minute"), - include(seconds, "second")] if a] - if len(parts) > 2: - return "{} and {}".format(", ".join(parts[:-1]), parts[-1]) - return " and ".join(parts) - - class CommandHandler: log = logging.getLogger("mau.commands") diff --git a/mautrix_telegram/commands/meta.py b/mautrix_telegram/commands/meta.py index 0338b402..e7ea94e0 100644 --- a/mautrix_telegram/commands/meta.py +++ b/mautrix_telegram/commands/meta.py @@ -50,9 +50,9 @@ def help(evt): **cancel** - Cancel an ongoing action (such as login). #### Authentication -**login** <_phone_> - Request an authentication code. -**logout** - Log out from Telegram. -**ping** - Check if you're logged into Telegram. +**login** - Request an authentication code. +**logout** - Log out from Telegram. +**ping** - Check if you're logged into Telegram. #### Initiating chats **search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users. diff --git a/mautrix_telegram/public/__init__.py b/mautrix_telegram/public/__init__.py new file mode 100644 index 00000000..08f9caa2 --- /dev/null +++ b/mautrix_telegram/public/__init__.py @@ -0,0 +1,158 @@ +# -*- 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 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 General Public License for more details. +# +# You should have received a copy of the GNU 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 + + +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) + if not user: + return self.render_login(mxid=request.rel_url.query["mxid"], state="request") + elif not user.whitelisted: + return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403) + await user.ensure_started() + if not user.logged_in: + return self.render_login(mxid=user.mxid, state="request") + + 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_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 banned for flooding. " + "The ban is usually applied for around a day.") + 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): + self.log.debug(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.whitelisted: + return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403) + elif user.logged_in: + return self.render_login(mxid=user.mxid, username=user.username) + + if "phone" in data: + return await self.post_login_phone(user, data["phone"]) + elif "code" in data: + resp = await self.post_login_code(user, data["code"], password_in_data="password" in data) + if resp: + 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/public/favicon.png b/mautrix_telegram/public/favicon.png new file mode 100644 index 00000000..c6b5fae7 Binary files /dev/null and b/mautrix_telegram/public/favicon.png differ diff --git a/mautrix_telegram/public/login.css b/mautrix_telegram/public/login.css new file mode 100644 index 00000000..95d461b8 --- /dev/null +++ b/mautrix_telegram/public/login.css @@ -0,0 +1,33 @@ +form > div { + display: none; +} + +form[data-status="request"] > div.status-request, +form[data-status="code"] > div.status-code, +form[data-status="password"] > div.status-password { + display: initial; +} + +.container { + margin-top: 3rem; + max-width: 60rem; +} + +.error, .message { + border-radius: .25rem; + padding: .5rem 1rem; + border: 1px solid transparent; + margin: .5rem 0; +} + +.error { + border-color: #f5c6cb; + background-color: #f8d7da; + color: #721c24; +} + +.message { + border-color: #c3e6cb; + background-color: #d4edda; + color: #155724; +} diff --git a/mautrix_telegram/public/login.html.mako b/mautrix_telegram/public/login.html.mako new file mode 100644 index 00000000..4da8d7d5 --- /dev/null +++ b/mautrix_telegram/public/login.html.mako @@ -0,0 +1,80 @@ + + + + Mautrix-Telegram bridge + + + + + + + + + + + +
+ % if username: + % if state == "logged-in": +

Logged in successfully!

+

+ Logged in as @${username}. + You can now close this page. + You should be invited to Telegram portals on Matrix momentarily. +

+ % else: +

You're already logged in!

+

+ You're logged in as @${username}. +

+

+ If you want to log in with another account, log out using the logout + management command first. +

+ % endif + % else: +

Log in to Telegram

+ % if error: +
${error}
+ % endif + % if message: +
${message}
+ % endif +
+
+ + + % if state == "request": + + + + % elif state == "code": + + + +
+ +
+ % elif state == "password": + + + +
+ +
+ % endif +
+
+ % endif +
+ + diff --git a/mautrix_telegram/util/__init__.py b/mautrix_telegram/util/__init__.py index 53d6aabd..c512529c 100644 --- a/mautrix_telegram/util/__init__.py +++ b/mautrix_telegram/util/__init__.py @@ -1 +1,2 @@ from .file_transfer import transfer_file_to_matrix +from .format_duration import format_duration diff --git a/mautrix_telegram/util/format_duration.py b/mautrix_telegram/util/format_duration.py new file mode 100644 index 00000000..ffbac714 --- /dev/null +++ b/mautrix_telegram/util/format_duration.py @@ -0,0 +1,34 @@ +# -*- 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 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +def format_duration(seconds): + def pluralize(count, singular): return singular if count == 1 else singular + "s" + + def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else "" + + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + parts = [a for a in [ + include(days, "day"), + include(hours, "hour"), + include(minutes, "minute"), + include(seconds, "second")] if a] + if len(parts) > 2: + return "{} and {}".format(", ".join(parts[:-1]), parts[-1]) + return " and ".join(parts) diff --git a/setup.py b/setup.py index 2b78675c..ac7f42b0 100644 --- a/setup.py +++ b/setup.py @@ -44,4 +44,6 @@ setuptools.setup( [console_scripts] mautrix-telegram=mautrix_telegram.__main__:main """, + package_data={"mautrix_telegram": ["public/*.html", "public/*.png", "public/*.css", "public/*.js"]}, + data_files=[(".", "example-config.yaml")], )