Refactor preparation for observers (#544)

* move node update to a separate method

* split specter.check

* split specter.check

* finish check separation

* monitor wallets for all users

* typo

* add user manager

* black

* fix default user

* check only current user by default
This commit is contained in:
Stepan Snigirev 2020-10-26 12:47:10 +01:00 committed by GitHub
parent 7fca3333e9
commit 7baa4e2cf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 350 additions and 224 deletions

View file

@ -277,7 +277,7 @@ def login():
# TODO: This way both "User" and "user" will pass as usernames, should there be strict check on that here? Or should we keep it like this?
username = request.form["username"]
password = request.form["password"]
user = User.get_user_by_name(app.specter, username)
user = app.specter.user_manager.get_user_by_username(username)
if user:
if verify_password(user.password, password):
app.login(user.id)
@ -320,9 +320,7 @@ def register():
password = hash_password(request.form["password"])
otp = request.form["otp"]
user_id = alias(username)
if User.get_user(app.specter, user_id) or User.get_user_by_name(
app.specter, username
):
if app.specter.user_manager.get_user(user_id):
flash("Username is already taken, please choose another one", "error")
return redirect("register?otp={}".format(otp))
if app.specter.burn_new_user_otp(otp):
@ -336,7 +334,7 @@ def register():
"hwi_bridge_url": "/hwi/api/",
}
user = User(user_id, username, password, config)
user.save_info(app.specter)
app.specter.add_user(user)
flash(
"You have registered successfully, \
please login with your new account to start using Specter"
@ -356,7 +354,6 @@ please request a new link from the node operator.",
def logout():
logout_user()
flash("You were logged out", "info")
app.specter.clear_user_session()
return redirect("login")
@ -592,7 +589,7 @@ def auth_settings():
new_otp = -1
users = None
if current_user.is_admin and auth == "usernamepassword":
users = [user for user in User.get_all_users(app.specter) if not user.is_admin]
users = [user for user in app.specter.user_manager.users if not user.is_admin]
if request.method == "POST":
action = request.form["action"]
@ -607,7 +604,7 @@ def auth_settings():
auth = request.form["auth"]
if specter_username:
if current_user.username != specter_username:
if User.get_user_by_name(app.specter, specter_username):
if app.specter.user_manager.get_user(specter_username):
flash(
"Username is already taken, please choose another one",
"error",
@ -631,7 +628,7 @@ def auth_settings():
if auth == "usernamepassword":
users = [
user
for user in User.get_all_users(app.specter)
for user in app.specter.user_manager.users
if not user.is_admin
]
else:
@ -661,23 +658,11 @@ def auth_settings():
)
elif action == "deleteuser":
delete_user = request.form["deleteuser"]
user = app.specter.user_manager.get_user(delete_user)
if current_user.is_admin:
user = User.get_user(app.specter, delete_user)
if user:
user.delete(app.specter)
users = [
user
for user in User.get_all_users(app.specter)
if not user.is_admin
]
flash(
"User {} was deleted successfully".format(user.username), "info"
)
else:
flash(
"Error: failed to delete user, invalid user ID was given",
"error",
)
app.specter.delete_user(user)
users.remove(user)
flash("User {} was deleted successfully".format(user.username), "info")
else:
flash("Error: Only the admin account can delete users", "error")
return render_template(
@ -979,7 +964,7 @@ def new_wallet(wallet_type):
if "use_explorer" in request.form:
explorer = app.specter.get_default_explorer()
wallet.rescanutxo(explorer)
app.specter._info["utxorescan"] = 1
app.specter.info["utxorescan"] = 1
app.specter.utxorescanwallet = wallet.alias
else:
app.logger.info("Rescanning Blockchain ...")
@ -1103,7 +1088,7 @@ def singlesig_setup_wizard():
if "use_explorer" in request.form:
explorer = app.specter.get_default_explorer()
wallet.rescanutxo(explorer)
app.specter._info["utxorescan"] = 1
app.specter.info["utxorescan"] = 1
app.specter.utxorescanwallet = wallet.alias
return redirect(url_for("wallet", wallet_alias=wallet.alias))
return render_template(
@ -1218,12 +1203,12 @@ def txout_set_info():
@login_required
def get_scantxoutset_status():
status = app.specter.rpc.scantxoutset("status", [])
app.specter._info["utxorescan"] = status.get("progress", None) if status else None
if app.specter._info["utxorescan"] is None:
app.specter.info["utxorescan"] = status.get("progress", None) if status else None
if app.specter.info["utxorescan"] is None:
app.specter.utxorescanwallet = None
return {
"active": app.specter._info["utxorescan"] is not None,
"progress": app.specter._info["utxorescan"],
"active": app.specter.info["utxorescan"] is not None,
"progress": app.specter.info["utxorescan"],
}
@ -1513,11 +1498,11 @@ def wallet_settings(wallet_alias):
if "use_explorer" in request.form:
explorer = app.specter.get_default_explorer()
wallet.rescanutxo(explorer)
app.specter._info["utxorescan"] = 1
app.specter.info["utxorescan"] = 1
app.specter.utxorescanwallet = wallet.alias
elif action == "abortrescanutxo":
app.specter.abortrescanutxo()
app.specter._info["utxorescan"] = None
app.specter.info["utxorescan"] = None
app.specter.utxorescanwallet = None
elif action == "keypoolrefill":
delta = int(request.form["keypooladd"])

View file

@ -18,6 +18,7 @@ class Device:
hwi_support = False
supports_hwi_toggle_passphrase = False
supports_hwi_multisig_display_address = False
hot_wallet = False
def __init__(self, name, alias, keys, fullpath, manager):
"""

View file

@ -6,7 +6,7 @@ from .rpc import get_default_datadir
from .devices import __all__ as device_classes
from .devices.generic import GenericDevice # default device type
from .persistence import write_device, delete_json_file
from .persistence import write_device, delete_json_file, delete_folder
logger = logging.getLogger(__name__)
@ -95,3 +95,10 @@ class DeviceManager:
@property
def supported_devices(self):
return device_classes
def delete(self, specter):
"""Deletes all the devices"""
for d in self.devices:
device = self.devices[d]
self.remove_device(device)
delete_folder(self.data_folder)

View file

@ -9,6 +9,7 @@ import logging
from contextlib import nullcontext
from flask import current_app as app
from .util.shell import run_shell
import shutil
logger = logging.getLogger(__name__)

View file

@ -66,7 +66,7 @@ def init_app(app, hwibridge=False, specter=None):
@login_manager.user_loader
def user_loader(id):
return User.get_user(specter, id)
return specter.user_manager.get_user(id)
def login(id):
login_user(user_loader(id))

View file

@ -13,8 +13,9 @@ from requests.exceptions import ConnectionError
from .rpc import BitcoinRPC
from .device_manager import DeviceManager
from .wallet_manager import WalletManager
from .user_manager import UserManager
from .persistence import write_json_file, read_json_file
from flask_login import current_user
from .user import User
import threading
logger = logging.getLogger(__name__)
@ -66,7 +67,6 @@ def get_rpc(conf, old_rpc=None):
class Specter:
""" A central Object mostly holding app-settings """
CONFIG_FILE_NAME = "config.json"
# use this lock for all fs operations
lock = threading.Lock()
@ -74,14 +74,21 @@ class Specter:
if data_folder.startswith("~"):
data_folder = os.path.expanduser(data_folder)
data_folder = os.path.abspath(data_folder)
# creating folders if they don't exist
if not os.path.isdir(data_folder):
os.makedirs(data_folder)
self.data_folder = data_folder
self.rpc = None
self.device_manager = None
self.wallet_manager = None
self._current_version = None
self.user_manager = UserManager(self)
self.file_config = None # what comes from config file
self.arg_config = config # what comes from arguments
# wallet that is currently rescnning with utxorescan
# can be only one at a time
self.utxorescanwallet = None
# default config
@ -105,42 +112,36 @@ class Specter:
"validate_merkle_proofs": False,
}
# creating folders if they don't exist
if not os.path.isdir(data_folder):
os.makedirs(data_folder)
# health check: loads config, tests rpc
# also loads and checks wallets for all users
self.check(check_all=True)
self.is_checking = False
# health check: loads config and tests rpc
self.check()
@property
def bitcoin_datadir(self):
if "datadir" in self.config["rpc"]:
return os.path.expanduser(self.config["rpc"]["datadir"])
return get_default_datadir()
def check(self, user=current_user):
# if config.json file exists - load from it
if os.path.isfile(os.path.join(self.data_folder, "config.json")):
with self.lock:
self.file_config = read_json_file(
os.path.join(self.data_folder, "config.json")
)
deep_update(self.config, self.file_config)
# otherwise - create one and assign unique id
else:
if self.config["uid"] == "":
self.config["uid"] = (
random.randint(0, 256 ** 8).to_bytes(8, "big").hex()
)
self._save()
# init arguments
deep_update(self.config, self.arg_config) # override loaded config
def check(self, user=None, check_all=False):
"""
Checks and updates everything for a particular user:
- config if changed
- rpc including check if it's connected
- node info
- wallet manager
- device manager
"""
# find proper user
user = self.user_manager.get_user(user)
# check if config file have changed
self.check_config()
# update rpc if something doesn't work
if self.rpc is None or not self.rpc.test_connection():
self.rpc = get_rpc(self.config["rpc"], self.rpc)
self.check_node_info()
if not check_all:
self.check_for_user(user)
else:
for u in self.user_manager.users:
self.check_for_user(u)
def check_node_info(self):
self._is_configured = self.rpc is not None
self._is_running = False
if self._is_configured:
@ -187,47 +188,106 @@ class Specter:
if not self._is_running:
self._info["chain"] = None
chain = self._info["chain"]
if hasattr(user, "is_admin"):
user_folder_id = "_" + user.id if user and not user.is_admin else ""
else:
user_folder_id = ""
def get_user_folder_id(self, user=None):
"""
Returns the suffix for the user wallets and devices.
User can be either a flask_login user or a string.
"""
user = self.user_manager.get_user(user)
if not user.is_admin:
return "_" + user.id
return ""
if self.config["auth"] != "usernamepassword" or (
user and not user.is_anonymous
def check_wallet_manager(self, user=None):
"""Updates wallet manager for a particular user"""
user = self.user_manager.get_user(user)
wallets_rpcpath = "specter%s" % self.config["uid"]
wallets_folder = os.path.join(self.data_folder, f"wallets{user.folder_id}")
# if chain, user or data folder changed
wallet_manager = user.wallet_manager
if (
wallet_manager is None
or wallet_manager.data_folder != self.data_folder
or wallet_manager.rpc_path != wallets_rpcpath
or wallet_manager.chain != self.chain
):
if self.device_manager is None:
self.device_manager = DeviceManager(
os.path.join(self.data_folder, "devices{}".format(user_folder_id))
)
else:
self.device_manager.update(
data_folder=os.path.join(
self.data_folder, "devices{}".format(user_folder_id)
)
)
wallet_manager = WalletManager(
wallets_folder,
self.rpc,
self.chain,
self.device_manager,
path=wallets_rpcpath,
)
user.wallet_manager = wallet_manager
else:
wallet_manager.update(wallets_folder, self.rpc, chain=self.chain)
wallets_path = "specter%s" % self.config["uid"]
# if chain, user or data folder changed
if (
self.wallet_manager is None
or self.wallet_manager.data_folder != self.data_folder
or self.wallet_manager.rpc_path != wallets_path
or self.wallet_manager.chain != chain
):
self.wallet_manager = WalletManager(
os.path.join(self.data_folder, "wallets{}".format(user_folder_id)),
self.rpc,
chain,
self.device_manager,
path=wallets_path,
)
else:
self.wallet_manager.update(
os.path.join(self.data_folder, "wallets{}".format(user_folder_id)),
self.rpc,
chain=chain,
def check_device_manager(self, user=None):
"""Updates device manager for a particular user"""
user = self.user_manager.get_user(user)
devices_folder = os.path.join(self.data_folder, f"devices{user.folder_id}")
device_manager = user.device_manager
if device_manager is None:
device_manager = DeviceManager(devices_folder)
user.device_manager = device_manager
else:
device_manager.update(data_folder=devices_folder)
def check_config(self):
"""
Updates config if file config have changed.
Priority (low to high):
- existing / default config
- file config from config.json
- arg_config passed in constructor
"""
# if config.json file exists - load from it
if os.path.isfile(self.config_fname):
with self.lock:
self.file_config = read_json_file(self.config_fname)
deep_update(self.config, self.file_config)
# otherwise - create one and assign unique id
else:
# unique id of specter
if self.config["uid"] == "":
self.config["uid"] = (
random.randint(0, 256 ** 8).to_bytes(8, "big").hex()
)
self._save()
# config from constructor overrides file config
deep_update(self.config, self.arg_config)
def check_for_user(self, user=None):
"""
Performs device and wallet manager check for particular user
"""
user = self.user_manager.get_user(user)
self.check_device_manager(user)
self.check_wallet_manager(user)
def add_user(self, user):
if user in self.user_manager.users:
return
user.wallet_manager = None
user.device_manager = None
self.user_manager.add_user(user)
self.check_for_user(user)
def delete_user(self, user):
if user not in self.user_manager.users:
return
user = self.user_manager.get_user(user)
user.wallet_manager.delete(self)
user.device_manager.delete(self)
self.user_manager.delete_user(user)
@property
def bitcoin_datadir(self):
if "datadir" in self.config["rpc"]:
return os.path.expanduser(self.config["rpc"]["datadir"])
return get_default_datadir()
def abortrescanutxo(self):
self.rpc.scantxoutset("abort", [])
@ -236,10 +296,6 @@ class Specter:
self._info["utxorescan"] = None
self.utxorescanwallet = None
def clear_user_session(self):
self.device_manager = None
self.wallet_manager = None
def test_rpc(self, **kwargs):
conf = copy.deepcopy(self.config["rpc"])
conf.update(kwargs)
@ -297,10 +353,14 @@ class Specter:
def _save(self):
write_json_file(
self.config,
os.path.join(self.data_folder, self.CONFIG_FILE_NAME),
self.config_fname,
lock=self.lock,
)
@property
def config_fname(self):
return os.path.join(self.data_folder, "config.json")
def update_rpc(self, **kwargs):
need_update = False
for k in kwargs:
@ -310,7 +370,7 @@ class Specter:
if need_update:
self.rpc = get_rpc(self.config["rpc"], None)
self._save()
self.check()
self.check(check_all=True)
return self.rpc is not None
def update_auth(self, auth):
@ -321,6 +381,7 @@ class Specter:
def update_explorer(self, explorer, user):
""" update the block explorers urls """
user = self.user_manager.get_user(user)
# we don't know what chain to change
if not self.chain:
return
@ -337,6 +398,7 @@ class Specter:
def update_hwi_bridge_url(self, url, user):
""" update the hwi bridge url to use """
user = self.user_manager.get_user(user)
if url and not url.endswith("/"):
# make sure the urls end with a "/"
url += "/"
@ -351,21 +413,21 @@ class Specter:
if not url.endswith("/hwi/api/"):
url += "hwi/api/"
if user.id == "admin":
if user.is_admin:
self.config["hwi_bridge_url"] = url
self._save()
else:
user.set_hwi_bridge_url(self, url)
def update_unit(self, unit, user):
if user.id == "admin":
if user.is_admin:
self.config["unit"] = unit
self._save()
else:
user.set_unit(self, unit)
def update_merkleproof_settings(self, validate_bool):
if validate_bool is True and self._info.get("pruned") is True:
if validate_bool is True and self.info.get("pruned") is True:
validate_bool = False
logger.warning("Cannot enable merkleproof setting on pruned node.")
@ -449,50 +511,39 @@ class Specter:
def chain(self):
return self._info["chain"]
@property
def user_config(self):
return self.config if self.user.is_admin else self.user.config
@property
def explorer(self):
# TODO: Unify for user and admin
if (not current_user or current_user.is_anonymous) or current_user.is_admin:
if "explorers" in self.config and self.chain in self.config["explorers"]:
return self.config["explorers"][self.chain]
else:
return ""
else:
if (
"explorers" in current_user.config
and self.chain in current_user.config["explorers"]
):
return current_user.config["explorers"][self.chain]
else:
return ""
return self.user_config.get("explorers", {}).get(self.chain, "")
@property
def hwi_bridge_url(self):
# TODO: Unify for user and admin
if (not current_user or current_user.is_anonymous) or current_user.is_admin:
if "hwi_bridge_url" in self.config:
return self.config["hwi_bridge_url"]
else:
return ""
else:
if "hwi_bridge_url" in current_user.config:
return current_user.config["hwi_bridge_url"]
else:
return ""
return self.user_config.get("hwi_bridge_url", "")
@property
def unit(self):
# TODO: Unify for user and admin
if (not current_user or current_user.is_anonymous) or current_user.is_admin:
if "unit" in self.config:
return self.config["unit"]
else:
return "btc"
else:
if "unit" in current_user.config:
return current_user.config["unit"]
else:
return "btc"
return self.user_config.get("unit", "btc")
@property
def admin(self):
for u in self.users:
if u.is_admin:
return u
@property
def user(self):
return self.user_manager.user
@property
def device_manager(self):
return self.user.device_manager
@property
def wallet_manager(self):
return self.user.wallet_manager
def specter_backup_file(self):
memory_file = BytesIO()

View file

@ -22,7 +22,7 @@
</h1>
{% if wallet.rescan_progress or specter.utxorescanwallet == wallet.alias %}
<span id="wallet_rescan_data" style="display: contents;">
<h2>Rescanning blockchain: <span id="{{ wallet.alias }}_balances_wallet_rescan_percents">{{ "%.2f"|format(specter._info["utxorescan"] if specter.utxorescanwallet == wallet.alias else wallet.rescan_progress * 100) }}</span>%</h2>
<h2>Rescanning blockchain: <span id="{{ wallet.alias }}_balances_wallet_rescan_percents">{{ "%.2f"|format(specter.info["utxorescan"] if specter.utxorescanwallet == wallet.alias else wallet.rescan_progress * 100) }}</span>%</h2>
<span class="warning">
&#9432;<br>
Total balance and transactions history may show outdated data during scanning.<br>Please wait until the scanning is complete before you start using the wallet.

View file

@ -33,29 +33,6 @@ def verify_password(stored_password, provided_password):
return pwdhash == binascii.a2b_base64(stored_password["pwdhash"])
def get_users_json(specter):
users = [
{
"id": "admin",
"username": "admin",
"password": hash_password("admin"),
"is_admin": True,
}
]
# if users.json file exists - load from it
if os.path.isfile(os.path.join(specter.data_folder, "users.json")):
users = read_json_file(os.path.join(specter.data_folder, "users.json"))
# otherwise - create one and assign unique id
else:
save_users_json(specter, users)
return users
def save_users_json(specter, users):
write_json_file(users, os.path.join(specter.data_folder, "users.json"))
class User(UserMixin):
def __init__(self, id, username, password, config, is_admin=False):
self.id = id
@ -63,6 +40,15 @@ class User(UserMixin):
self.password = password
self.config = config
self.is_admin = is_admin
self.wallet_manager = None
self.device_manager = None
self.manager = None
@property
def folder_id(self):
if self.is_admin:
return ""
return f"_{self.id}"
@classmethod
def from_json(cls, user_dict):
@ -86,31 +72,6 @@ class User(UserMixin):
except:
raise SpecterError("Unable to parse user JSON.")
@classmethod
def get_user(cls, specter, id):
users = get_users_json(specter)
for user_dict in users:
user = User.from_json(user_dict)
if user.id == id:
return user
@classmethod
def get_user_by_name(cls, specter, username):
users = get_users_json(specter)
for user_dict in users:
user = User.from_json(user_dict)
if user.username == username:
return user
@classmethod
def get_all_users(cls, specter):
users_dicts = get_users_json(specter)
users = []
for user_dict in users_dicts:
user = User.from_json(user_dict)
users.append(user)
return users
@property
def json(self):
user_dict = {
@ -124,20 +85,17 @@ class User(UserMixin):
return user_dict
def save_info(self, specter, delete=False):
users = get_users_json(specter)
existing = False
for i in range(len(users)):
if users[i]["id"] == self.id:
if not delete:
users[i] = self.json
existing = True
else:
del users[i]
break
if not existing and not delete:
users.append(self.json)
if self.manager is None:
self.manager = specter.user_manager
users = self.manager.users
existing = self in users
save_users_json(specter, users)
# update specter users
if not existing and not delete:
specter.add_user(self)
if existing and delete:
specter.delete_user(self)
self.manager.save()
def set_explorer(self, specter, explorer):
self.config["explorers"][specter.chain] = explorer
@ -152,12 +110,23 @@ class User(UserMixin):
self.save_info(specter)
def delete(self, specter):
devices_datadir_path = os.path.join(
os.path.join(specter.data_folder, "devices_{}".format(self.id))
)
wallets_datadir_path = os.path.join(
os.path.join(specter.data_folder, "wallets_{}".format(self.id))
)
delete_folder(devices_datadir_path)
delete_folder(wallets_datadir_path)
# we delete wallet manager and device manager in save_info
self.save_info(specter, delete=True)
def __eq__(self, other):
if isinstance(other, str):
return self.id == other
return self.id == other.id
def __ne__(self, other):
return not self.__eq__(other)
def __hash__(self):
# to make lookups in dicts by user id
return hash(self.id)
def __str__(self):
return self.id
def __repr__(self):
return f"User({self.id})"

View file

@ -0,0 +1,105 @@
import os
import json
import logging
from .helpers import alias, load_jsons, fslock
from .persistence import (
read_json_file,
write_json_file,
delete_json_file,
delete_folder,
)
from .user import User, hash_password
from flask_login import current_user
logger = logging.getLogger(__name__)
class UserManager:
"""
The UserManager can manage users
"""
# of them via json-files in an empty data folder
def __init__(self, specter):
self.specter = specter
self.data_folder = specter.data_folder
self.update()
@property
def users_file(self):
return os.path.join(self.data_folder, "users.json")
def load_users(self):
# if users.json file exists - load from it
if os.path.isfile(self.users_file):
users = read_json_file(self.users_file)
# otherwise - create one and assign unique id
else:
users = [
{
"id": "admin",
"username": "admin",
"password": hash_password("admin"),
"is_admin": True,
}
]
# convert to User instances
self.users = [User.from_json(u) for u in users]
if not os.path.isfile(self.users_file):
self.save()
def update(self):
self.load_users()
def save(self):
users_json = [u.json for u in self.users]
write_json_file(users_json, self.users_file)
def add_user(self, user):
if user not in self.users:
self.users.append(user)
user.manager = self
self.save() # save files
return self.get_user(user)
@property
def admin(self):
"""There is always one admin"""
for u in self.users:
if u.is_admin:
return u
def get_user(self, user=None):
"""
Converts from flask_login user to a User in the system.
Admin by default.
"""
# get by string
if user is None:
user = current_user
if isinstance(user, str):
return self.get_by_uid(user)
if user and not user.is_anonymous:
if user in self.users:
return self.get_by_uid(user.id)
return self.admin
def get_user_by_username(self, username):
for u in self.users:
if u.username == username:
return u
@property
def user(self):
"""User in current context, admin if no context"""
return self.get_user(current_user)
def get_by_uid(self, uid):
for u in self.users:
if u.id == uid:
return u
logger.error("Could not find user %s" % uid)
def delete_user(self, user):
self.users.remove(user)
self.save()

View file

@ -6,7 +6,7 @@ from .helpers import alias, load_jsons
from .rpc import get_default_datadir, RpcError
from .specter_error import SpecterError
from .wallet import Wallet
from .persistence import delete_json_file
from .persistence import delete_json_file, delete_folder
logger = logging.getLogger()
@ -287,3 +287,10 @@ Silently ignored!"
for tx in txlist:
result.append(tx)
return list(reversed(sorted(result, key=lambda tx: tx["time"])))
def delete(self, specter):
"""Deletes all the wallets"""
for w in self.wallets:
wallet = self.wallets[w]
self.delete_wallet(wallet, specter.bitcoin_datadir, specter.chain)
delete_folder(self.data_folder)