diff --git a/.gitmodules b/.gitmodules index 2b2c88706..1010e0dd9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,7 +2,7 @@ path = contrib/deterministic-build/electrum-locale url = https://github.com/spesmilo/electrum-locale [submodule "electrum/www"] - path = electrum/www + path = electrum/plugins/payserver/www url = https://github.com/spesmilo/electrum-http.git [submodule "electrum/gui/kivy/theming/atlas"] path = electrum/gui/kivy/theming/atlas diff --git a/electrum/daemon.py b/electrum/daemon.py index 2377cd133..e0c4fc184 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -53,6 +53,7 @@ from .simple_config import SimpleConfig from .exchange_rate import FxThread from .logging import get_logger, Logger from . import GuiImportError +from .plugin import run_hook if TYPE_CHECKING: from electrum import gui @@ -357,111 +358,6 @@ class WatchTowerServer(AuthenticatedServer): return await self.lnwatcher.sweepstore.add_sweep_tx(*args) -class PayServer(Logger, EventListener): - - WWW_DIR = os.path.join(os.path.dirname(__file__), 'www') - - def __init__(self, daemon: 'Daemon', netaddress): - Logger.__init__(self) - assert self.has_www_dir(), self.WWW_DIR - self.addr = netaddress - self.daemon = daemon - self.config = daemon.config - self.pending = defaultdict(asyncio.Event) - self.register_callbacks() - - @classmethod - def has_www_dir(cls) -> bool: - index_html = os.path.join(cls.WWW_DIR, "index.html") - return os.path.exists(index_html) - - @property - def wallet(self): - # FIXME specify wallet somehow? - return list(self.daemon.get_wallets().values())[0] - - @event_listener - async def on_event_request_status(self, wallet, key, status): - if status == PR_PAID: - self.pending[key].set() - - @ignore_exceptions - @log_exceptions - async def run(self): - self.root = root = self.config.get('payserver_root', '/r') - app = web.Application() - app.add_routes([web.get('/api/get_invoice', self.get_request)]) - app.add_routes([web.get('/api/get_status', self.get_status)]) - app.add_routes([web.get('/bip70/{key}.bip70', self.get_bip70_request)]) - # 'follow_symlinks=True' allows symlinks to traverse out the parent directory. - # This was requested by distro packagers for vendored libs, and we restrict it to only those - # to minimise attack surface. note: "add_routes" call order matters (inner path goes first) - app.add_routes([web.static(f"{root}/vendor", os.path.join(self.WWW_DIR, 'vendor'), follow_symlinks=True)]) - app.add_routes([web.static(root, self.WWW_DIR)]) - if self.config.get('payserver_allow_create_invoice'): - app.add_routes([web.post('/api/create_invoice', self.create_request)]) - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, host=str(self.addr.host), port=self.addr.port, ssl_context=self.config.get_ssl_context()) - await site.start() - self.logger.info(f"now running and listening. addr={self.addr}") - - async def create_request(self, request): - params = await request.post() - wallet = self.wallet - if 'amount_sat' not in params or not params['amount_sat'].isdigit(): - raise web.HTTPUnsupportedMediaType() - amount = int(params['amount_sat']) - message = params['message'] or "donation" - key = wallet.create_request( - amount_sat=amount, - message=message, - exp_delay=3600, - address=None) - raise web.HTTPFound(self.root + '/pay?id=' + key) - - async def get_request(self, r): - key = r.query_string - request = self.wallet.get_formatted_request(key) - return web.json_response(request) - - async def get_bip70_request(self, r): - from .paymentrequest import make_request - key = r.match_info['key'] - request = self.wallet.get_request(key) - if not request: - return web.HTTPNotFound() - pr = make_request(self.config, request) - return web.Response(body=pr.SerializeToString(), content_type='application/bitcoin-paymentrequest') - - async def get_status(self, request): - ws = web.WebSocketResponse() - await ws.prepare(request) - key = request.query_string - info = self.wallet.get_formatted_request(key) - if not info: - await ws.send_str('unknown invoice') - await ws.close() - return ws - if info.get('status') == PR_PAID: - await ws.send_str(f'paid') - await ws.close() - return ws - if info.get('status') == PR_EXPIRED: - await ws.send_str(f'expired') - await ws.close() - return ws - while True: - try: - await asyncio.wait_for(self.pending[key].wait(), 1) - break - except asyncio.TimeoutError: - # send data on the websocket, to keep it alive - await ws.send_str('waiting') - await ws.send_str('paid') - await ws.close() - return ws - class Daemon(Logger): @@ -496,15 +392,6 @@ class Daemon(Logger): if listen_jsonrpc: self.commands_server = CommandsServer(self, fd) daemon_jobs.append(self.commands_server.run()) - # pay server - self.pay_server = None - payserver_address = self.config.get_netaddress('payserver_address') - if not config.get('offline') and payserver_address: - if PayServer.has_www_dir(): - self.pay_server = PayServer(self, payserver_address) - daemon_jobs.append(self.pay_server.run()) - else: - self.logger.error(f"PayServer configured but WWW_DIR missing or empty. skipping. ({PayServer.WWW_DIR})") # server-side watchtower self.watchtower = None watchtower_address = self.config.get_netaddress('watchtower_address') @@ -559,6 +446,7 @@ class Daemon(Logger): return wallet.start_network(self.network) self._wallets[path] = wallet + run_hook('daemon_wallet_loaded', self, wallet) return wallet @staticmethod diff --git a/electrum/plugins/payserver/__init__.py b/electrum/plugins/payserver/__init__.py new file mode 100644 index 000000000..6b1411038 --- /dev/null +++ b/electrum/plugins/payserver/__init__.py @@ -0,0 +1,5 @@ +from electrum.i18n import _ + +fullname = _('PayServer') +description = 'run a HTTP server for receiving payments' +available_for = ['qt', 'cmdline'] diff --git a/electrum/plugins/payserver/cmdline.py b/electrum/plugins/payserver/cmdline.py new file mode 100644 index 000000000..c2d8945b2 --- /dev/null +++ b/electrum/plugins/payserver/cmdline.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# +# Electrum - Lightweight Bitcoin Client +# Copyright (C) 2022 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from .payserver import PayServerPlugin + +class Plugin(PayServerPlugin): + pass + diff --git a/electrum/plugins/payserver/payserver.py b/electrum/plugins/payserver/payserver.py new file mode 100644 index 000000000..343daf565 --- /dev/null +++ b/electrum/plugins/payserver/payserver.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python +# +# Electrum - Lightweight Bitcoin Client +# Copyright (C) 2022 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os +import asyncio +from collections import defaultdict + +from aiohttp import ClientResponse +from aiohttp import web, client_exceptions +from aiorpcx import timeout_after, TaskTimeout, ignore_after +from aiorpcx import NetAddress + + +from electrum.util import log_exceptions, ignore_exceptions +from electrum.crypto import sha256 +from electrum.plugin import BasePlugin, hook +from electrum.logging import Logger + + +from electrum.logging import Logger +from electrum.util import EventListener, event_listener +from electrum.invoices import PR_PAID, PR_EXPIRED + + + +class PayServerPlugin(BasePlugin): + + def __init__(self, parent, config, name): + BasePlugin.__init__(self, parent, config, name) + self.config = config + self.server = None + + @hook + def daemon_wallet_loaded(self, daemon, wallet): + # we use the first wallet loaded + if self.server is not None: + return + if self.config.get('offline'): + return + self.server = PayServer(self.config, wallet) + asyncio.run_coroutine_threadsafe(daemon._run(jobs=[self.server.run()]), daemon.asyncio_loop) + + + +class PayServer(Logger, EventListener): + + WWW_DIR = os.path.join(os.path.dirname(__file__), 'www') + + def __init__(self, config, wallet): + Logger.__init__(self) + assert self.has_www_dir(), self.WWW_DIR + self.config = config + self.wallet = wallet + url = self.config.get('payserver_address', 'localhost:8080') + self.addr = NetAddress.from_string(url) + self.pending = defaultdict(asyncio.Event) + self.register_callbacks() + + @classmethod + def has_www_dir(cls) -> bool: + index_html = os.path.join(cls.WWW_DIR, "index.html") + return os.path.exists(index_html) + + @event_listener + async def on_event_request_status(self, wallet, key, status): + if status == PR_PAID: + self.pending[key].set() + + @ignore_exceptions + @log_exceptions + async def run(self): + self.root = root = self.config.get('payserver_root', '/r') + app = web.Application() + app.add_routes([web.get('/api/get_invoice', self.get_request)]) + app.add_routes([web.get('/api/get_status', self.get_status)]) + app.add_routes([web.get('/bip70/{key}.bip70', self.get_bip70_request)]) + # 'follow_symlinks=True' allows symlinks to traverse out the parent directory. + # This was requested by distro packagers for vendored libs, and we restrict it to only those + # to minimise attack surface. note: "add_routes" call order matters (inner path goes first) + app.add_routes([web.static(f"{root}/vendor", os.path.join(self.WWW_DIR, 'vendor'), follow_symlinks=True)]) + app.add_routes([web.static(root, self.WWW_DIR)]) + if self.config.get('payserver_allow_create_invoice'): + app.add_routes([web.post('/api/create_invoice', self.create_request)]) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host=str(self.addr.host), port=self.addr.port, ssl_context=self.config.get_ssl_context()) + await site.start() + self.logger.info(f"now running and listening. addr={self.addr}") + + async def create_request(self, request): + params = await request.post() + wallet = self.wallet + if 'amount_sat' not in params or not params['amount_sat'].isdigit(): + raise web.HTTPUnsupportedMediaType() + amount = int(params['amount_sat']) + message = params['message'] or "donation" + key = wallet.create_request( + amount_sat=amount, + message=message, + exp_delay=3600, + address=None) + raise web.HTTPFound(self.root + '/pay?id=' + key) + + async def get_request(self, r): + key = r.query_string + request = self.wallet.get_formatted_request(key) + return web.json_response(request) + + async def get_bip70_request(self, r): + from .paymentrequest import make_request + key = r.match_info['key'] + request = self.wallet.get_request(key) + if not request: + return web.HTTPNotFound() + pr = make_request(self.config, request) + return web.Response(body=pr.SerializeToString(), content_type='application/bitcoin-paymentrequest') + + async def get_status(self, request): + ws = web.WebSocketResponse() + await ws.prepare(request) + key = request.query_string + info = self.wallet.get_formatted_request(key) + if not info: + await ws.send_str('unknown invoice') + await ws.close() + return ws + if info.get('status') == PR_PAID: + await ws.send_str(f'paid') + await ws.close() + return ws + if info.get('status') == PR_EXPIRED: + await ws.send_str(f'expired') + await ws.close() + return ws + while True: + try: + await asyncio.wait_for(self.pending[key].wait(), 1) + break + except asyncio.TimeoutError: + # send data on the websocket, to keep it alive + await ws.send_str('waiting') + await ws.send_str('paid') + await ws.close() + return ws + diff --git a/electrum/plugins/payserver/qt.py b/electrum/plugins/payserver/qt.py new file mode 100644 index 000000000..929f64742 --- /dev/null +++ b/electrum/plugins/payserver/qt.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# +# Electrum - Lightweight Bitcoin Client +# Copyright (C) 2022 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from .payserver import PayServerPlugin + +class Plugin(PayServerPlugin): + pass diff --git a/electrum/www b/electrum/plugins/payserver/www similarity index 100% rename from electrum/www rename to electrum/plugins/payserver/www