tIntegrate http_server (previously in electrum-merchant) Use submodule to fetch HTML and CSS files - electrum - Electrum Bitcoin wallet (HTM) git clone https://git.parazyd.org/electrum (DIR) Log (DIR) Files (DIR) Refs (DIR) Submodules --- (DIR) commit 747ab7a0a255f183e61feded3f4a0ec804fbde35 (DIR) parent bd578807997ee6dc13e6a5e996d7c3eb68c01af0 (HTM) Author: ThomasV <thomasv@electrum.org> Date: Tue, 3 Sep 2019 14:44:33 +0200 Integrate http_server (previously in electrum-merchant) Use submodule to fetch HTML and CSS files Diffstat: M .gitmodules | 3 +++ M electrum/commands.py | 1 - M electrum/daemon.py | 79 +++++++++++++++++++++++++++++++ M electrum/gui/qt/invoice_list.py | 2 +- M electrum/gui/qt/main_window.py | 5 ++--- M electrum/gui/qt/request_list.py | 63 +++++++++++++------------------ M electrum/wallet.py | 65 ++++++------------------------- D electrum/websockets.py | 132 ------------------------------- A electrum/www | 1 + M run_electrum | 9 --------- 10 files changed, 123 insertions(+), 237 deletions(-) --- (DIR) diff --git a/.gitmodules b/.gitmodules t@@ -4,3 +4,6 @@ [submodule "contrib/CalinsQRReader"] path = contrib/osx/CalinsQRReader url = https://github.com/spesmilo/CalinsQRReader +[submodule "electrum/www"] + path = electrum/www + url = git@github.com:spesmilo/electrum-http.git (DIR) diff --git a/electrum/commands.py b/electrum/commands.py t@@ -1039,7 +1039,6 @@ arg_types = { config_variables = { 'addrequest': { - 'requests_dir': 'directory where a bip70 file will be written.', 'ssl_privkey': 'Path to your SSL private key, needed to sign the request.', 'ssl_chain': 'Chain of SSL certificates, needed for signed requests. Put your certificate at the top and the root CA at the end', 'url_rewrite': 'Parameters passed to str.replace(), in order to create the r= part of bitcoin: URIs. Example: \"(\'file:///var/www/\',\'https://electrum.org/\')\"', (DIR) diff --git a/electrum/daemon.py b/electrum/daemon.py t@@ -33,6 +33,7 @@ from typing import Dict, Optional, Tuple import aiohttp from aiohttp import web from base64 import b64decode +from collections import defaultdict import jsonrpcclient import jsonrpcserver t@@ -41,6 +42,7 @@ from jsonrpcclient.clients.aiohttp_client import AiohttpClient from .network import Network from .util import (json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare) +from .util import PR_PAID, PR_EXPIRED, get_request_status from .wallet import Wallet, Abstract_Wallet from .storage import WalletStorage from .commands import known_commands, Commands t@@ -168,6 +170,79 @@ class WatchTowerServer(Logger): async def add_sweep_tx(self, *args): return await self.lnwatcher.sweepstore.add_sweep_tx(*args) +class HttpServer(Logger): + + def __init__(self, daemon): + Logger.__init__(self) + self.daemon = daemon + self.config = daemon.config + self.pending = defaultdict(asyncio.Event) + self.daemon.network.register_callback(self.on_payment, ['payment_received']) + + async def on_payment(self, evt, *args): + print(evt, args) + #await self.pending[key].set() + + async def run(self): + from aiohttp import helpers + app = web.Application() + #app.on_response_prepare.append(http_server.on_response_prepare) + app.add_routes([web.post('/api/create_invoice', self.create_request)]) + 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.static('/electrum', 'electrum/www')]) + runner = web.AppRunner(app) + await runner.setup() + host = self.config.get('http_host', 'localhost') + port = self.config.get('http_port', 8000) + site = web.TCPSite(runner, port=port, host=host) + await site.start() + + async def create_request(self, request): + params = await request.post() + wallet = self.daemon.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" + payment_hash = await wallet.lnworker._add_invoice_coro(amount, message, 3600) + key = payment_hash.hex() + raise web.HTTPFound('/electrum/index.html?id=' + key) + + async def get_request(self, r): + key = r.query_string + request = self.daemon.wallet.get_request(key) + return web.json_response(request) + + async def get_status(self, request): + ws = web.WebSocketResponse() + await ws.prepare(request) + key = request.query_string + info = self.daemon.wallet.get_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'already paid') + await ws.close() + return ws + if info.get('status') == PR_EXPIRED: + await ws.send_str(f'invoice 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 AuthenticationError(Exception): pass t@@ -197,6 +272,9 @@ class Daemon(Logger): if listen_jsonrpc: jobs.append(self.start_jsonrpc(config, fd)) # server-side watchtower + self.http_server = HttpServer(self) + if self.http_server: + jobs.append(self.http_server.run()) self.watchtower = WatchTowerServer(self.network) if self.config.get('watchtower_host') else None if self.watchtower: jobs.append(self.watchtower.run) t@@ -296,6 +374,7 @@ class Daemon(Logger): wallet = Wallet(storage) wallet.start_network(self.network) self.wallets[path] = wallet + self.wallet = wallet return wallet def add_wallet(self, wallet: Abstract_Wallet): (DIR) diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py t@@ -151,4 +151,4 @@ class InvoiceList(MyTreeView): def create_menu_ln_payreq(self, menu, payreq_key): req = self.parent.wallet.lnworker.invoices[payreq_key][0] menu.addAction(_("Copy Lightning invoice"), lambda: self.parent.do_copy('Lightning invoice', req)) - menu.addAction(_("Delete"), lambda: self.parent.delete_lightning_payreq(payreq_key)) + menu.addAction(_("Delete"), lambda: self.parent.delete_invoice(payreq_key)) (DIR) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py t@@ -1028,9 +1028,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return w - - def delete_payment_request(self, addr): - self.wallet.remove_payment_request(addr, self.config) + def delete_request(self, key): + self.wallet.delete_request(key) self.request_list.update() self.clear_receive_tab() (DIR) diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py t@@ -40,13 +40,11 @@ from electrum.bitcoin import COIN from electrum.lnaddr import lndecode import electrum.constants as constants -from .util import MyTreeView, pr_icons, read_QIcon +from .util import MyTreeView, pr_icons, read_QIcon, webopen -REQUEST_TYPE_BITCOIN = 0 -REQUEST_TYPE_LN = 1 ROLE_REQUEST_TYPE = Qt.UserRole -ROLE_RHASH_OR_ADDR = Qt.UserRole + 1 +ROLE_KEY = Qt.UserRole + 1 class RequestList(MyTreeView): t@@ -76,7 +74,7 @@ class RequestList(MyTreeView): def select_key(self, key): for i in range(self.model().rowCount()): item = self.model().index(i, self.Columns.DATE) - row_key = item.data(ROLE_RHASH_OR_ADDR) + row_key = item.data(ROLE_KEY) if key == row_key: self.selectionModel().setCurrentIndex(item, QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows) break t@@ -85,12 +83,12 @@ class RequestList(MyTreeView): # TODO use siblingAtColumn when min Qt version is >=5.11 item = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE)) request_type = item.data(ROLE_REQUEST_TYPE) - key = item.data(ROLE_RHASH_OR_ADDR) - is_lightning = request_type == REQUEST_TYPE_LN - req = self.wallet.get_request(key, is_lightning) + key = item.data(ROLE_KEY) + req = self.wallet.get_request(key) if req is None: self.update() return + is_lightning = request_type == PR_TYPE_LN text = req.get('invoice') if is_lightning else req.get('URI') self.parent.receive_address_e.setText(text) t@@ -101,9 +99,9 @@ class RequestList(MyTreeView): date_idx = idx.sibling(idx.row(), self.Columns.DATE) date_item = m.itemFromIndex(date_idx) status_item = m.itemFromIndex(idx) - key = date_item.data(ROLE_RHASH_OR_ADDR) - is_lightning = date_item.data(ROLE_REQUEST_TYPE) == REQUEST_TYPE_LN - req = self.wallet.get_request(key, is_lightning) + key = date_item.data(ROLE_KEY) + is_lightning = date_item.data(ROLE_REQUEST_TYPE) == PR_TYPE_LN + req = self.wallet.get_request(key) if req: status = req['status'] status_str = get_request_status(req) t@@ -121,7 +119,7 @@ class RequestList(MyTreeView): if status == PR_PAID: continue is_lightning = req['type'] == PR_TYPE_LN - request_type = REQUEST_TYPE_LN if is_lightning else REQUEST_TYPE_BITCOIN + request_type = req['type'] timestamp = req.get('time', 0) amount = req.get('amount') message = req['message'] if is_lightning else req['memo'] t@@ -133,18 +131,17 @@ class RequestList(MyTreeView): self.set_editability(items) items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE) items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) - if request_type == REQUEST_TYPE_LN: - items[self.Columns.DATE].setData(req['rhash'], ROLE_RHASH_OR_ADDR) + if request_type == PR_TYPE_LN: + items[self.Columns.DATE].setData(req['rhash'], ROLE_KEY) items[self.Columns.DATE].setIcon(read_QIcon("lightning.png")) - items[self.Columns.DATE].setData(REQUEST_TYPE_LN, ROLE_REQUEST_TYPE) - else: + elif request_type == PR_TYPE_ADDRESS: address = req['address'] if address not in domain: continue expiration = req.get('exp', None) signature = req.get('sig') requestor = req.get('name', '') - items[self.Columns.DATE].setData(address, ROLE_RHASH_OR_ADDR) + items[self.Columns.DATE].setData(address, ROLE_KEY) if signature is not None: items[self.Columns.DATE].setIcon(read_QIcon("seal.png")) items[self.Columns.DATE].setToolTip(f'signed by {requestor}') t@@ -167,13 +164,9 @@ class RequestList(MyTreeView): item = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE)) if not item: return - addr = item.data(ROLE_RHASH_OR_ADDR) + key = item.data(ROLE_KEY) request_type = item.data(ROLE_REQUEST_TYPE) - assert request_type in [REQUEST_TYPE_BITCOIN, REQUEST_TYPE_LN] - if request_type == REQUEST_TYPE_BITCOIN: - req = self.wallet.receive_requests.get(addr) - elif request_type == REQUEST_TYPE_LN: - req = self.wallet.lnworker.invoices[addr][0] + req = self.wallet.get_request(key) if req is None: self.update() return t@@ -184,19 +177,15 @@ class RequestList(MyTreeView): if column == self.Columns.AMOUNT: column_data = column_data.strip() menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.do_copy(column_title, column_data)) - if request_type == REQUEST_TYPE_BITCOIN: - self.create_menu_bitcoin_payreq(menu, addr) - elif request_type == REQUEST_TYPE_LN: - self.create_menu_ln_payreq(menu, addr, req) - menu.exec_(self.viewport().mapToGlobal(position)) - def create_menu_bitcoin_payreq(self, menu, addr): - menu.addAction(_("Copy Address"), lambda: self.parent.do_copy('Address', addr)) - menu.addAction(_("Copy URI"), lambda: self.parent.do_copy('URI', self.wallet.get_request_URI(addr))) - menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr)) - menu.addAction(_("Delete"), lambda: self.parent.delete_payment_request(addr)) - run_hook('receive_list_menu', menu, addr) + #menu.addAction(_("Copy Address"), lambda: self.parent.do_copy('Address', addr)) + menu.addAction(_("Copy Request"), lambda: self.parent.do_copy('URI', self.wallet.get_request_URI(addr))) + if 'http_url' in req: + menu.addAction(_("View in web browser"), lambda: webopen(req['http_url'])) - def create_menu_ln_payreq(self, menu, payreq_key, req): - menu.addAction(_("Copy Lightning invoice"), lambda: self.parent.do_copy('Lightning invoice', req)) - menu.addAction(_("Delete"), lambda: self.parent.delete_lightning_payreq(payreq_key)) + # do bip70 only for browser access + # so, each request should have an ID, regardless + #menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr)) + menu.addAction(_("Delete"), lambda: self.parent.delete_request(key)) + run_hook('receive_list_menu', menu, key) + menu.exec_(self.viewport().mapToGlobal(position)) (DIR) diff --git a/electrum/wallet.py b/electrum/wallet.py t@@ -1279,32 +1279,6 @@ class Abstract_Wallet(AddressSynchronizer): out['status'] = status if conf is not None: out['confirmations'] = conf - # check if bip70 file exists - rdir = config.get('requests_dir') - if rdir: - key = out.get('id', addr) - path = os.path.join(rdir, 'req', key[0], key[1], key) - if os.path.exists(path): - baseurl = 'file://' + rdir - rewrite = config.get('url_rewrite') - if rewrite: - try: - baseurl = baseurl.replace(*rewrite) - except BaseException as e: - self.logger.info(f'Invalid config setting for "url_rewrite". err: {e}') - out['request_url'] = os.path.join(baseurl, 'req', key[0], key[1], key, key) - out['URI'] += '&r=' + out['request_url'] - out['index_url'] = os.path.join(baseurl, 'index.html') + '?id=' + key - websocket_server_announce = config.get('websocket_server_announce') - if websocket_server_announce: - out['websocket_server'] = websocket_server_announce - else: - out['websocket_server'] = config.get('websocket_server', 'localhost') - websocket_port_announce = config.get('websocket_port_announce') - if websocket_port_announce: - out['websocket_port'] = websocket_port_announce - else: - out['websocket_port'] = config.get('websocket_port', 9999) return out def get_request_URI(self, addr): t@@ -1346,11 +1320,19 @@ class Abstract_Wallet(AddressSynchronizer): status = PR_INFLIGHT if conf <= 0 else PR_PAID return status, conf - def get_request(self, key, is_lightning): - if not is_lightning: + def get_request(self, key): + from .simple_config import get_config + config = get_config() + if key in self.receive_requests: req = self.get_payment_request(key, {}) else: req = self.lnworker.get_request(key) + if not req: + return + if config.get('http_port', 8000): + host = config.get('http_host', 'localhost') + port = config.get('http_port', 8000) + req['http_url'] = 'http://%s:%d/electrum/index.html?id=%s'%(host, port, key) return req def receive_tx_callback(self, tx_hash, tx, tx_height): t@@ -1389,24 +1371,6 @@ class Abstract_Wallet(AddressSynchronizer): self.receive_requests[addr] = req self.storage.put('payment_requests', self.receive_requests) self.set_label(addr, message) # should be a default label - - rdir = config.get('requests_dir') - if rdir and amount is not None: - key = req.get('id', addr) - pr = paymentrequest.make_request(config, req) - path = os.path.join(rdir, 'req', key[0], key[1], key) - if not os.path.exists(path): - try: - os.makedirs(path) - except OSError as exc: - if exc.errno != errno.EEXIST: - raise - with open(os.path.join(path, key), 'wb') as f: - f.write(pr.SerializeToString()) - # reload - req = self.get_payment_request(addr, config) - with open(os.path.join(path, key + '.json'), 'w', encoding='utf-8') as f: - f.write(json.dumps(req)) return req def delete_request(self, key): t@@ -1427,14 +1391,7 @@ class Abstract_Wallet(AddressSynchronizer): def remove_payment_request(self, addr, config): if addr not in self.receive_requests: return False - r = self.receive_requests.pop(addr) - rdir = config.get('requests_dir') - if rdir: - key = r.get('id', addr) - for s in ['.json', '']: - n = os.path.join(rdir, 'req', key[0], key[1], key, key + s) - if os.path.exists(n): - os.unlink(n) + self.receive_requests.pop(addr) self.storage.put('payment_requests', self.receive_requests) return True (DIR) diff --git a/electrum/websockets.py b/electrum/websockets.py t@@ -1,132 +0,0 @@ -#!/usr/bin/env python -# -# Electrum - lightweight Bitcoin client -# Copyright (C) 2015 Thomas Voegtlin -# -# 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 threading -import os -import json -from collections import defaultdict -import asyncio -from typing import Dict, List, Tuple, TYPE_CHECKING -import traceback -import sys - -try: - from SimpleWebSocketServer import WebSocket, SimpleSSLWebSocketServer -except ImportError: - sys.exit("install SimpleWebSocketServer") - -from . import bitcoin -from .synchronizer import SynchronizerBase -from .logging import Logger - -if TYPE_CHECKING: - from .network import Network - from .simple_config import SimpleConfig - - -request_queue = asyncio.Queue() - - -class ElectrumWebSocket(WebSocket, Logger): - - def __init__(self): - WebSocket.__init__(self) - Logger.__init__(self) - - def handleMessage(self): - assert self.data[0:3] == 'id:' - self.logger.info(f"message received {self.data}") - request_id = self.data[3:] - asyncio.run_coroutine_threadsafe( - request_queue.put((self, request_id)), asyncio.get_event_loop()) - - def handleConnected(self): - self.logger.info(f"connected {self.address}") - - def handleClose(self): - self.logger.info(f"closed {self.address}") - - -class BalanceMonitor(SynchronizerBase): - - def __init__(self, config: 'SimpleConfig', network: 'Network'): - SynchronizerBase.__init__(self, network) - self.config = config - self.expected_payments = defaultdict(list) # type: Dict[str, List[Tuple[WebSocket, int]]] - - def make_request(self, request_id): - # read json file - rdir = self.config.get('requests_dir') - n = os.path.join(rdir, 'req', request_id[0], request_id[1], request_id, request_id + '.json') - with open(n, encoding='utf-8') as f: - s = f.read() - d = json.loads(s) - addr = d.get('address') - amount = d.get('amount') - return addr, amount - - async def main(self): - # resend existing subscriptions if we were restarted - for addr in self.expected_payments: - await self._add_address(addr) - # main loop - while True: - ws, request_id = await request_queue.get() - try: - addr, amount = self.make_request(request_id) - except Exception: - self.logger.exception('') - continue - self.expected_payments[addr].append((ws, amount)) - await self._add_address(addr) - - async def _on_address_status(self, addr, status): - self.logger.info(f'new status for addr {addr}') - sh = bitcoin.address_to_scripthash(addr) - balance = await self.network.get_balance_for_scripthash(sh) - for ws, amount in self.expected_payments[addr]: - if not ws.closed: - if sum(balance.values()) >= amount: - ws.sendMessage('paid') - - -class WebSocketServer(threading.Thread): - - def __init__(self, config: 'SimpleConfig', network: 'Network'): - threading.Thread.__init__(self) - self.config = config - self.network = network - asyncio.set_event_loop(network.asyncio_loop) - self.daemon = True - self.balance_monitor = BalanceMonitor(self.config, self.network) - self.start() - - def run(self): - asyncio.set_event_loop(self.network.asyncio_loop) - host = self.config.get('websocket_server') - port = self.config.get('websocket_port', 9999) - certfile = self.config.get('ssl_chain') - keyfile = self.config.get('ssl_privkey') - self.server = SimpleSSLWebSocketServer(host, port, ElectrumWebSocket, certfile, keyfile) - self.server.serveforever() (DIR) diff --git a/electrum/www b/electrum/www t@@ -0,0 +1 @@ +Subproject commit 538fa508d41512e670fb84970f821a5db71836d9 (DIR) diff --git a/run_electrum b/run_electrum t@@ -375,15 +375,6 @@ if __name__ == '__main__': # run daemon init_plugins(config, 'cmdline') d = daemon.Daemon(config, fd) - if config.get('websocket_server'): - from electrum import websockets - websockets.WebSocketServer(config, d.network) - if config.get('requests_dir'): - path = os.path.join(config.get('requests_dir'), 'index.html') - if not os.path.exists(path): - print("Requests directory not configured.") - print("You can configure it using https://github.com/spesmilo/electrum-merchant") - sys_exit(1) d.run_daemon() sys_exit(0) else: