diff --git a/example-config.yaml b/example-config.yaml index 1aa2a508..32a178e1 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 diff --git a/mautrix_appservice/appservice.py b/mautrix_appservice/appservice.py index 37ea3ea4..bc237007 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 9af163e8..27115a88 100644 --- a/mautrix_telegram/__main__.py +++ b/mautrix_telegram/__main__.py @@ -32,6 +32,7 @@ from .db import init as init_db from .user import init as init_user, User from .portal import init as init_portal from .puppet import init as init_puppet +from .public import PublicBridgeWebsite log = logging.getLogger("mau") time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s") @@ -73,12 +74,16 @@ 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) -context = (appserv, db_session, config, loop) +az = AppService(config["homeserver.address"], config["homeserver.domain"], + config["appservice.as_token"], config["appservice.hs_token"], + config["appservice.bot_username"], log="mau.as", loop=loop) +context = (az, db_session, config, loop) -with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start: +if config["appservice.public.enabled"]: + public = PublicBridgeWebsite(loop) + az.app.add_subapp(config.get("appservice.public.prefix", "/public"), public.app) + +with az.run(config["appservice.hostname"], config["appservice.port"]) as start: MatrixHandler(context) init_db(db_session) init_portal(context) diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index 6c114785..3797f651 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -44,12 +44,26 @@ async def login(evt): elif len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp login `") phone_number = evt.args[0] - 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.") + try: + 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 Exception: + evt.log.exception("Error requesting phone code") + return await evt.reply("Unhandled exception while requesting code. " + "Check console for more details.") @command_handler(needs_auth=False) @@ -63,32 +77,23 @@ async def enter_code(evt): 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." + 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 0e06e3a4..a297ea8a 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 = {} @@ -65,24 +67,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/util.py b/mautrix_telegram/commands/util.py new file mode 100644 index 00000000..ffbac714 --- /dev/null +++ b/mautrix_telegram/commands/util.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/mautrix_telegram/public/__init__.py b/mautrix_telegram/public/__init__.py new file mode 100644 index 00000000..890d24cb --- /dev/null +++ b/mautrix_telegram/public/__init__.py @@ -0,0 +1,137 @@ +# -*- 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): + return self.render_login( + request.rel_url.query["mxid"] if "mxid" in request.rel_url.query else "") + + def render_login(self, mxid, state="request", phone="", code="", password="", + error="", message="", username="", status=200): + return web.Response(status=status, + content_type="text/html", + text=self.login.render(mxid=mxid, state=state, phone=phone, code=code, + message=message, username=username, error=error, + password=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 = User.get_by_mxid(data["mxid"]) + if not user.whitelisted: + return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403) + + if "phone" in data: + try: + await user.client.sign_in(data["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.") + elif "code" in data: + try: + user_info = await user.client.sign_in(code=data["code"]) + asyncio.ensure_future(user.post_login(user_info), loop=self.loop) + if 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 "password" not in data: + if 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, + error="Code accepted, but you have 2-factor authentication is enabled.") + 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.") + elif "password" not in data: + return self.render_login(error="No data given.", status=400) + + if "password" in data: + try: + user_info = await user.client.sign_in(password=data["password"]) + asyncio.ensure_future(user.post_login(user_info), loop=self.loop) + if 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.") 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..461ec7e0 --- /dev/null +++ b/mautrix_telegram/public/login.css @@ -0,0 +1,9 @@ +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; +} diff --git a/mautrix_telegram/public/login.html.mako b/mautrix_telegram/public/login.html.mako new file mode 100644 index 00000000..c2aefbf0 --- /dev/null +++ b/mautrix_telegram/public/login.html.mako @@ -0,0 +1,41 @@ + + + + Mautrix-Telegram bridge + + + + + + + + +
+ % if state == "logged-in": +

Logged in successfully!

+

Logged in as @${username}

+ % 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/setup.py b/setup.py index 6f02919e..c7874097 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")], )