diff --git a/.gitignore b/.gitignore index 0d03e87f..5a91a419 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ plugins/Multiuser plugins/NoNewSites plugins/StemPort plugins/UiPassword + +# Build files +src/Build.py diff --git a/CHANGELOG.md b/CHANGELOG.md index edfcada8..2343a2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ### zeronet-conservancy 0.7.10+ - disable site-plugins installed for security reasons (@caryoscelus) - fix downloading geoip db (@caryoscelus) -- python <3.6 is officially unsupported +- python <3.8 is officially unsupported - SafeRe improvements by @geekless - remove and don't update muted files (@caryoscelus) - option to disable port checking (@caryoscelus) @@ -11,6 +11,9 @@ - better fix of local sites leak (@caryoscelus) - ipython-based repl via --repl for debug/interactive development (@caryoscelus) - optional blocking of compromised id certificates for spam protection (@caryoscelus) +- changes in directory structure (split data and config, use user directories by default) +- use version information from git if available +- different build types (portable vs package) - various improvements ### zeronet-conservancy 0.7.10 (2023-07-26) (18d35d3bed4f0683e99) diff --git a/build.py b/build.py new file mode 100755 index 00000000..e142816a --- /dev/null +++ b/build.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +## Copyright (c) 2024 caryoscelus +## +## zeronet-conservancy 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. +## +## zeronet-conservancy 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 +## zeronet-conservancy. If not, see . +## + +"""Simple build/bundle script +""" + +import argparse + +def write_to(args, target): + branch = args.branch + commit = args.commit + if branch is None or commit is None: + from src.util import Git + branch = branch or Git.branch() or 'unknown' + commit = commit or Git.commit() or 'unknown' + target.write('\n'.join([ + f"build_type = {args.type!r}", + f"branch = {branch!r}", + f"commit = {commit!r}", + f"version = {args.version!r}", + f"platform = {args.platform!r}", + ])) + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--type', default='source') + parser.add_argument('--version') + parser.add_argument('--branch') + parser.add_argument('--commit') + parser.add_argument('--platform', default='source') + parser.add_argument('--stdout', action=argparse.BooleanOptionalAction, default=False) + args = parser.parse_args() + if args.stdout: + import sys + target = sys.stdout + else: + target = open('src/Build.py', 'w') + write_to(args, target) + +if __name__ == '__main__': + main() diff --git a/plugins/AnnounceShare/AnnounceSharePlugin.py b/plugins/AnnounceShare/AnnounceSharePlugin.py index b350cf42..2a8a3891 100644 --- a/plugins/AnnounceShare/AnnounceSharePlugin.py +++ b/plugins/AnnounceShare/AnnounceSharePlugin.py @@ -14,7 +14,7 @@ from util import helper class TrackerStorage(object): def __init__(self): self.log = logging.getLogger("TrackerStorage") - self.file_path = "%s/trackers.json" % config.data_dir + self.file_path = config.start_dir / 'trackers.json' self.load() self.time_discover = 0.0 atexit.register(self.save) diff --git a/plugins/AnnounceShare/Test/TestAnnounceShare.py b/plugins/AnnounceShare/Test/TestAnnounceShare.py index 7178eac8..5b820f9b 100644 --- a/plugins/AnnounceShare/Test/TestAnnounceShare.py +++ b/plugins/AnnounceShare/Test/TestAnnounceShare.py @@ -9,7 +9,7 @@ from Config import config @pytest.mark.usefixtures("resetTempSettings") class TestAnnounceShare: def testAnnounceList(self, file_server): - open("%s/trackers.json" % config.data_dir, "w").write("{}") + (config.start_dir / 'trackers.json').open('w').write('{}') tracker_storage = AnnounceSharePlugin.tracker_storage tracker_storage.load() peer = Peer(file_server.ip, 1544, connection_server=file_server) diff --git a/plugins/Chart/ChartDb.py b/plugins/Chart/ChartDb.py index 66a22082..3bb449e8 100644 --- a/plugins/Chart/ChartDb.py +++ b/plugins/Chart/ChartDb.py @@ -6,7 +6,7 @@ import time class ChartDb(Db): def __init__(self): self.version = 2 - super(ChartDb, self).__init__(self.getSchema(), "%s/chart.db" % config.data_dir) + super(ChartDb, self).__init__(self.getSchema(), config.start_dir / 'chart.db') self.foreign_keys = True self.checkTables() self.sites = self.loadSites() diff --git a/plugins/ContentFilter/ContentFilterStorage.py b/plugins/ContentFilter/ContentFilterStorage.py index 2ad378d6..7d62e7e4 100644 --- a/plugins/ContentFilter/ContentFilterStorage.py +++ b/plugins/ContentFilter/ContentFilterStorage.py @@ -14,7 +14,7 @@ from util import helper class ContentFilterStorage(object): def __init__(self, site_manager): self.log = logging.getLogger("ContentFilterStorage") - self.file_path = "%s/filters.json" % config.data_dir + self.file_path = config.config_dir / 'filters.json' self.site_manager = site_manager self.file_content = self.load() @@ -36,12 +36,12 @@ class ContentFilterStorage(object): def load(self): # Rename previously used mutes.json -> filters.json - if os.path.isfile("%s/mutes.json" % config.data_dir): + if (config.config_dir / 'mutes.json').is_file(): self.log.info("Renaming mutes.json to filters.json...") - os.rename("%s/mutes.json" % config.data_dir, self.file_path) - if os.path.isfile(self.file_path): + os.rename(config.config_dir / 'mutes.json', self.file_path) + if self.file_path.is_file(): try: - return json.load(open(self.file_path)) + return json.load(self.file_path.open()) except Exception as err: self.log.error("Error loading filters.json: %s" % err) return None diff --git a/plugins/FilePack/FilePackPlugin.py b/plugins/FilePack/FilePackPlugin.py index 1c931316..488ff1a0 100644 --- a/plugins/FilePack/FilePackPlugin.py +++ b/plugins/FilePack/FilePackPlugin.py @@ -44,7 +44,7 @@ class UiRequestPlugin(object): if ".zip/" in path or ".tar.gz/" in path: file_obj = None path_parts = self.parsePath(path) - file_path = "%s/%s/%s" % (config.data_dir, path_parts["address"], path_parts["inner_path"]) + file_path = config.data_dir / path_parts["address"] / path_parts["inner_path"] match = re.match(r"^(.*\.(?:tar.gz|zip))/(.*)", file_path) archive_path, path_within = match.groups() if archive_path not in archive_cache: diff --git a/plugins/Sidebar/SidebarPlugin.py b/plugins/Sidebar/SidebarPlugin.py index b8c5f0f3..78c4f39a 100644 --- a/plugins/Sidebar/SidebarPlugin.py +++ b/plugins/Sidebar/SidebarPlugin.py @@ -686,7 +686,7 @@ class UiWebsocketPlugin(object): if sys.platform == "linux": sys_db_paths += ['/usr/share/GeoIP/' + db_name] - data_dir_db_path = os.path.join(config.data_dir, db_name) + data_dir_db_path = config.start_dir / db_name db_paths = sys_db_paths + [data_dir_db_path] diff --git a/plugins/disabled-Bootstrapper/BootstrapperDb.py b/plugins/disabled-Bootstrapper/BootstrapperDb.py index 0866dc3e..355fac8c 100644 --- a/plugins/disabled-Bootstrapper/BootstrapperDb.py +++ b/plugins/disabled-Bootstrapper/BootstrapperDb.py @@ -12,7 +12,7 @@ class BootstrapperDb(Db.Db): def __init__(self): self.version = 7 self.hash_ids = {} # hash -> id cache - super(BootstrapperDb, self).__init__({"db_name": "Bootstrapper"}, "%s/bootstrapper.db" % config.data_dir) + super(BootstrapperDb, self).__init__({"db_name": "Bootstrapper"}, config.start_dir / 'bootstrapper.db') self.foreign_keys = True self.checkTables() self.updateHashCache() diff --git a/plugins/disabled-Multiuser/MultiuserPlugin.py b/plugins/disabled-Multiuser/MultiuserPlugin.py index a2fd79ae..342307bf 100644 --- a/plugins/disabled-Multiuser/MultiuserPlugin.py +++ b/plugins/disabled-Multiuser/MultiuserPlugin.py @@ -16,7 +16,7 @@ def importPluginnedClasses(): from User import UserManager try: - local_master_addresses = set(json.load(open("%s/users.json" % config.data_dir)).keys()) # Users in users.json + local_master_addresses = set(json.load((config.private_dir / 'users.json').open()).keys()) # Users in users.json except Exception as err: local_master_addresses = set() diff --git a/plugins/disabled-Multiuser/Test/TestMultiuser.py b/plugins/disabled-Multiuser/Test/TestMultiuser.py index b8ff4267..fe03833d 100644 --- a/plugins/disabled-Multiuser/Test/TestMultiuser.py +++ b/plugins/disabled-Multiuser/Test/TestMultiuser.py @@ -8,7 +8,8 @@ from User import UserManager class TestMultiuser: def testMemorySave(self, user): # It should not write users to disk - users_before = open("%s/users.json" % config.data_dir).read() + users_json = config.private_dir / 'users.json' + users_before = users_json.open().read() user = UserManager.user_manager.create() user.save() - assert open("%s/users.json" % config.data_dir).read() == users_before + assert users_json.open().read() == users_before diff --git a/src/Actions.py b/src/Actions.py new file mode 100644 index 00000000..72c8b063 --- /dev/null +++ b/src/Actions.py @@ -0,0 +1,521 @@ +import logging +import sys +import gevent +from Config import config +from Plugin import PluginManager + +@PluginManager.acceptPlugins +class Actions: + def call(self, function_name, kwargs): + logging.info(f'zeronet-conservancy {config.version_full} on Python {sys.version} Gevent {gevent.__version__}') + + func = getattr(self, function_name, None) + back = func(**kwargs) + if back: + print(back) + + def ipythonThread(self): + import IPython + IPython.embed() + self.gevent_quit.set() + + # Default action: Start serving UiServer and FileServer + def main(self): + import main + from File import FileServer + from Ui import UiServer + logging.info("Creating FileServer....") + main.file_server = FileServer() + logging.info("Creating UiServer....") + main.ui_server = UiServer() + main.file_server.ui_server = main.ui_server + + # for startup_error in startup_errors: + # logging.error("Startup error: %s" % startup_error) + + logging.info("Removing old SSL certs...") + from Crypt import CryptConnection + CryptConnection.manager.removeCerts() + + logging.info("Starting servers....") + + import threading + self.gevent_quit = threading.Event() + launched_greenlets = [gevent.spawn(main.ui_server.start), gevent.spawn(main.file_server.start), gevent.spawn(main.ui_server.startSiteServer)] + + # if --repl, start ipython thread + # FIXME: Unfortunately this leads to exceptions on exit so use with care + if config.repl: + threading.Thread(target=self.ipythonThread).start() + + stopped = 0 + # Process all greenlets in main thread + while not self.gevent_quit.is_set() and stopped < len(launched_greenlets): + stopped += len(gevent.joinall(launched_greenlets, timeout=1)) + + # Exited due to repl, so must kill greenlets + if stopped < len(launched_greenlets): + gevent.killall(launched_greenlets, exception=KeyboardInterrupt) + + logging.info("All server stopped") + + # Site commands + + def siteCreate(self, use_master_seed=True): + logging.info("Generating new privatekey (use_master_seed: %s)..." % config.use_master_seed) + from Crypt import CryptBitcoin + if use_master_seed: + from User import UserManager + user = UserManager.user_manager.get() + if not user: + user = UserManager.user_manager.create() + address, address_index, site_data = user.getNewSiteData() + privatekey = site_data["privatekey"] + logging.info("Generated using master seed from users.json, site index: %s" % address_index) + else: + privatekey = CryptBitcoin.newPrivatekey() + address = CryptBitcoin.privatekeyToAddress(privatekey) + logging.info("----------------------------------------------------------------------") + logging.info("Site private key: %s" % privatekey) + logging.info(" !!! ^ Save it now, required to modify the site ^ !!!") + logging.info("Site address: %s" % address) + logging.info("----------------------------------------------------------------------") + + while True and not config.batch and not use_master_seed: + if input("? Have you secured your private key? (yes, no) > ").lower() == "yes": + break + else: + logging.info("Please, secure it now, you going to need it to modify your site!") + + logging.info("Creating directory structure...") + from Site.Site import Site + from Site import SiteManager + SiteManager.site_manager.load() + + (config.data_dir / address).mkdir() + (config.data_dir / address / 'index.html').open('w').write(f"Hello {address}!") + + logging.info("Creating content.json...") + site = Site(address) + extend = {"postmessage_nonce_security": True} + if use_master_seed: + extend["address_index"] = address_index + + site.content_manager.sign(privatekey=privatekey, extend=extend) + site.settings["own"] = True + site.saveSettings() + + logging.info("Site created!") + + def siteSign(self, address, privatekey=None, inner_path="content.json", publish=False, remove_missing_optional=False): + from Site.Site import Site + from Site import SiteManager + from Debug import Debug + SiteManager.site_manager.load() + logging.info("Signing site: %s..." % address) + site = Site(address, allow_create=False) + + if not privatekey: # If no privatekey defined + from User import UserManager + user = UserManager.user_manager.get() + if user: + site_data = user.getSiteData(address) + privatekey = site_data.get("privatekey") + else: + privatekey = None + if not privatekey: + # Not found in users.json, ask from console + import getpass + privatekey = getpass.getpass("Private key (input hidden):") + # inner_path can be either relative to site directory or absolute/relative path + if os.path.isabs(inner_path): + full_path = os.path.abspath(inner_path) + else: + full_path = os.path.abspath(config.working_dir + '/' + inner_path) + print(full_path) + if os.path.isfile(full_path): + if address in full_path: + # assuming site address is unique, keep only path after it + inner_path = full_path.split(address+'/')[1] + else: + # oops, file that we found seems to be rogue, so reverting to old behaviour + logging.warning(f'using {inner_path} relative to site directory') + try: + succ = site.content_manager.sign( + inner_path=inner_path, privatekey=privatekey, + update_changed_files=True, remove_missing_optional=remove_missing_optional + ) + except Exception as err: + logging.error("Sign error: %s" % Debug.formatException(err)) + succ = False + if succ and publish: + self.sitePublish(address, inner_path=inner_path) + + def siteVerify(self, address): + import time + from Site.Site import Site + from Site import SiteManager + SiteManager.site_manager.load() + + s = time.time() + logging.info("Verifing site: %s..." % address) + site = Site(address) + bad_files = [] + + for content_inner_path in site.content_manager.contents: + s = time.time() + logging.info("Verifing %s signature..." % content_inner_path) + error = None + try: + file_correct = site.content_manager.verifyFile( + content_inner_path, site.storage.open(content_inner_path, "rb"), ignore_same=False + ) + except Exception as err: + file_correct = False + error = err + + if file_correct is True: + logging.info("[OK] %s (Done in %.3fs)" % (content_inner_path, time.time() - s)) + else: + logging.error("[ERROR] %s: invalid file: %s!" % (content_inner_path, error)) + input("Continue?") + bad_files += content_inner_path + + logging.info("Verifying site files...") + bad_files += site.storage.verifyFiles()["bad_files"] + if not bad_files: + logging.info("[OK] All file sha512sum matches! (%.3fs)" % (time.time() - s)) + else: + logging.error("[ERROR] Error during verifying site files!") + + def dbRebuild(self, address): + from Site.Site import Site + from Site import SiteManager + SiteManager.site_manager.load() + + logging.info("Rebuilding site sql cache: %s..." % address) + site = SiteManager.site_manager.get(address) + s = time.time() + try: + site.storage.rebuildDb() + logging.info("Done in %.3fs" % (time.time() - s)) + except Exception as err: + logging.error(err) + + def dbQuery(self, address, query): + from Site.Site import Site + from Site import SiteManager + SiteManager.site_manager.load() + + import json + site = Site(address) + result = [] + for row in site.storage.query(query): + result.append(dict(row)) + print(json.dumps(result, indent=4)) + + def siteAnnounce(self, address): + from Site.Site import Site + from Site import SiteManager + SiteManager.site_manager.load() + + logging.info("Opening a simple connection server") + from File import FileServer + main.file_server = FileServer("127.0.0.1", 1234) + main.file_server.start() + + logging.info("Announcing site %s to tracker..." % address) + site = Site(address) + + s = time.time() + site.announce() + print("Response time: %.3fs" % (time.time() - s)) + print(site.peers) + + def siteDownload(self, address): + from Site.Site import Site + from Site import SiteManager + SiteManager.site_manager.load() + + logging.info("Opening a simple connection server") + from File import FileServer + main.file_server = FileServer("127.0.0.1", 1234) + file_server_thread = gevent.spawn(main.file_server.start, check_sites=False) + + site = Site(address) + + on_completed = gevent.event.AsyncResult() + + def onComplete(evt): + evt.set(True) + + site.onComplete.once(lambda: onComplete(on_completed)) + print("Announcing...") + site.announce() + + s = time.time() + print("Downloading...") + site.downloadContent("content.json", check_modifications=True) + + print("Downloaded in %.3fs" % (time.time()-s)) + + def siteNeedFile(self, address, inner_path): + from Site.Site import Site + from Site import SiteManager + SiteManager.site_manager.load() + + def checker(): + while 1: + s = time.time() + time.sleep(1) + print("Switch time:", time.time() - s) + gevent.spawn(checker) + + logging.info("Opening a simple connection server") + from File import FileServer + main.file_server = FileServer("127.0.0.1", 1234) + file_server_thread = gevent.spawn(main.file_server.start, check_sites=False) + + site = Site(address) + site.announce() + print(site.needFile(inner_path, update=True)) + + def siteCmd(self, address, cmd, parameters): + import json + from Site import SiteManager + + site = SiteManager.site_manager.get(address) + + if not site: + logging.error("Site not found: %s" % address) + return None + + ws = self.getWebsocket(site) + + ws.send(json.dumps({"cmd": cmd, "params": parameters, "id": 1})) + res_raw = ws.recv() + + try: + res = json.loads(res_raw) + except Exception as err: + return {"error": "Invalid result: %s" % err, "res_raw": res_raw} + + if "result" in res: + return res["result"] + else: + return res + + def importBundle(self, bundle): + import main + main.importBundle(bundle) + + def getWebsocket(self, site): + import websocket + + ws_address = "ws://%s:%s/Websocket?wrapper_key=%s" % (config.ui_ip, config.ui_port, site.settings["wrapper_key"]) + logging.info("Connecting to %s" % ws_address) + ws = websocket.create_connection(ws_address) + return ws + + def sitePublish(self, address, peer_ip=None, peer_port=15441, inner_path="content.json", recursive=False): + from Site import SiteManager + logging.info("Loading site...") + site = SiteManager.site_manager.get(address) + site.settings["serving"] = True # Serving the site even if its disabled + + if not recursive: + inner_paths = [inner_path] + else: + inner_paths = list(site.content_manager.contents.keys()) + + try: + ws = self.getWebsocket(site) + + except Exception as err: + self.sitePublishFallback(site, peer_ip, peer_port, inner_paths, err) + + else: + logging.info("Sending siteReload") + self.siteCmd(address, "siteReload", inner_path) + + for inner_path in inner_paths: + logging.info(f"Sending sitePublish for {inner_path}") + self.siteCmd(address, "sitePublish", {"inner_path": inner_path, "sign": False}) + logging.info("Done.") + ws.close() + + def sitePublishFallback(self, site, peer_ip, peer_port, inner_paths, err): + if err is not None: + logging.info(f"Can't connect to local websocket client: {err}") + logging.info("Publish using fallback mechanism. " + "Note that there might be not enough time for peer discovery, " + "but you can specify target peer on command line.") + logging.info("Creating FileServer....") + file_server_thread = gevent.spawn(main.file_server.start, check_sites=False) # Dont check every site integrity + time.sleep(0.001) + + # Started fileserver + main.file_server.portCheck() + if peer_ip: # Announce ip specificed + site.addPeer(peer_ip, peer_port) + else: # Just ask the tracker + logging.info("Gathering peers from tracker") + site.announce() # Gather peers + + for inner_path in inner_paths: + published = site.publish(5, inner_path) # Push to peers + + if published > 0: + time.sleep(3) + logging.info("Serving files (max 60s)...") + gevent.joinall([file_server_thread], timeout=60) + logging.info("Done.") + else: + logging.info("No peers found, sitePublish command only works if you already have visitors serving your site") + + # Crypto commands + def cryptPrivatekeyToAddress(self, privatekey=None): + from Crypt import CryptBitcoin + if not privatekey: # If no privatekey in args then ask it now + import getpass + privatekey = getpass.getpass("Private key (input hidden):") + + print(CryptBitcoin.privatekeyToAddress(privatekey)) + + def cryptSign(self, message, privatekey): + from Crypt import CryptBitcoin + print(CryptBitcoin.sign(message, privatekey)) + + def cryptVerify(self, message, sign, address): + from Crypt import CryptBitcoin + print(CryptBitcoin.verify(message, address, sign)) + + def cryptGetPrivatekey(self, master_seed, site_address_index=None): + from Crypt import CryptBitcoin + if len(master_seed) != 64: + logging.error("Error: Invalid master seed length: %s (required: 64)" % len(master_seed)) + return False + privatekey = CryptBitcoin.hdPrivatekey(master_seed, site_address_index) + print("Requested private key: %s" % privatekey) + + # Peer + def peerPing(self, peer_ip, peer_port=None): + if not peer_port: + peer_port = 15441 + logging.info("Opening a simple connection server") + from Connection import ConnectionServer + main.file_server = ConnectionServer("127.0.0.1", 1234) + main.file_server.start(check_connections=False) + from Crypt import CryptConnection + CryptConnection.manager.loadCerts() + + from Peer import Peer + logging.info("Pinging 5 times peer: %s:%s..." % (peer_ip, int(peer_port))) + s = time.time() + peer = Peer(peer_ip, peer_port) + peer.connect() + + if not peer.connection: + print("Error: Can't connect to peer (connection error: %s)" % peer.connection_error) + return False + if "shared_ciphers" in dir(peer.connection.sock): + print("Shared ciphers:", peer.connection.sock.shared_ciphers()) + if "cipher" in dir(peer.connection.sock): + print("Cipher:", peer.connection.sock.cipher()[0]) + if "version" in dir(peer.connection.sock): + print("TLS version:", peer.connection.sock.version()) + print("Connection time: %.3fs (connection error: %s)" % (time.time() - s, peer.connection_error)) + + for i in range(5): + ping_delay = peer.ping() + print("Response time: %.3fs" % ping_delay) + time.sleep(1) + peer.remove() + print("Reconnect test...") + peer = Peer(peer_ip, peer_port) + for i in range(5): + ping_delay = peer.ping() + print("Response time: %.3fs" % ping_delay) + time.sleep(1) + + def peerGetFile(self, peer_ip, peer_port, site, filename, benchmark=False): + logging.info("Opening a simple connection server") + from Connection import ConnectionServer + main.file_server = ConnectionServer("127.0.0.1", 1234) + main.file_server.start(check_connections=False) + from Crypt import CryptConnection + CryptConnection.manager.loadCerts() + + from Peer import Peer + logging.info("Getting %s/%s from peer: %s:%s..." % (site, filename, peer_ip, peer_port)) + peer = Peer(peer_ip, peer_port) + s = time.time() + if benchmark: + for i in range(10): + peer.getFile(site, filename), + print("Response time: %.3fs" % (time.time() - s)) + input("Check memory") + else: + print(peer.getFile(site, filename).read()) + + def peerCmd(self, peer_ip, peer_port, cmd, parameters): + logging.info("Opening a simple connection server") + from Connection import ConnectionServer + main.file_server = ConnectionServer() + main.file_server.start(check_connections=False) + from Crypt import CryptConnection + CryptConnection.manager.loadCerts() + + from Peer import Peer + peer = Peer(peer_ip, peer_port) + + import json + if parameters: + parameters = json.loads(parameters.replace("'", '"')) + else: + parameters = {} + try: + res = peer.request(cmd, parameters) + print(json.dumps(res, indent=2, ensure_ascii=False)) + except Exception as err: + print("Unknown response (%s): %s" % (err, res)) + + def getConfig(self): + import json + print(json.dumps(config.getServerInfo(), indent=2, ensure_ascii=False)) + + def test(self, test_name, *args, **kwargs): + import types + def funcToName(func_name): + test_name = func_name.replace("test", "") + return test_name[0].lower() + test_name[1:] + + test_names = [funcToName(name) for name in dir(self) if name.startswith("test") and name != "test"] + if not test_name: + # No test specificed, list tests + print("\nNo test specified, possible tests:") + for test_name in test_names: + func_name = "test" + test_name[0].upper() + test_name[1:] + func = getattr(self, func_name) + if func.__doc__: + print("- %s: %s" % (test_name, func.__doc__.strip())) + else: + print("- %s" % test_name) + return None + + # Run tests + func_name = "test" + test_name[0].upper() + test_name[1:] + if hasattr(self, func_name): + func = getattr(self, func_name) + print("- Running test: %s" % test_name, end="") + s = time.time() + ret = func(*args, **kwargs) + if type(ret) is types.GeneratorType: + for progress in ret: + print(progress, end="") + sys.stdout.flush() + print("\n* Test %s done in %.3fs" % (test_name, time.time() - s)) + else: + print("Unknown test: %r (choose from: %s)" % ( + test_name, test_names + )) diff --git a/src/Config.py b/src/Config.py index 72e10d92..b6212f17 100644 --- a/src/Config.py +++ b/src/Config.py @@ -9,23 +9,35 @@ import logging import logging.handlers import stat import time +from pathlib import Path + +VERSION = "0.7.10+" + +class StartupError(RuntimeError): + pass class Config: + """Class responsible for storing and loading config. + + Used as singleton `config` + """ def __init__(self, argv): try: from . import Build except ImportError: - print('cannot find build') from .util import Git self.build_type = 'source' self.branch = Git.branch() or 'unknown' self.commit = Git.commit() or 'unknown' + self.version = VERSION + self.platform = 'source' else: self.build_type = Build.build_type self.branch = Build.branch self.commit = Build.commit - self.version = "0.7.10+" + self.version = Build.version or VERSION + self.platform = Build.platform self.version_full = f'{self.version} ({self.build_type} from {self.branch}-{self.commit})' self.user_agent = "conservancy" # for compatibility @@ -43,15 +55,18 @@ class Config: self.keys_restart_need = set([ "tor", "fileserver_port", "fileserver_ip_type", "threads_fs_read", "threads_fs_write", "threads_crypt", "threads_db" ]) - self.start_dir = self.getStartDir() - self.config_file = self.start_dir + "/zeronet.conf" - self.data_dir = self.start_dir + "/data" - self.log_dir = self.start_dir + "/log" + self.config_file = None + self.config_dir = None + self.data_dir = None + self.private_dir = None + self.log_dir = None + self.configurePaths(argv) + self.openssl_lib_file = None self.openssl_bin_file = None - self.trackers_file = False + self.trackers_file = None self.createParser() self.createArguments() @@ -68,7 +83,8 @@ class Config: def strToBool(self, v): return v.lower() in ("yes", "true", "t", "1") - def getStartDir(self): + def getStartDirOld(self): + """Get directory that would have been used by older versions (pre v0.7.11)""" this_file = os.path.abspath(__file__).replace("\\", "/").rstrip("cd") if "--start-dir" in self.argv: @@ -89,9 +105,127 @@ class Config: start_dir = os.path.expanduser("~/ZeroNet") else: start_dir = "." - return start_dir + def migrateOld(self, source): + print(f'[bold red]WARNING: found data {source}[/bold red]') + print( ' It used to be default behaviour to store data there,') + print( ' but now we default to place data and config in user home directory.') + print( '') + + def configurePaths(self, argv): + if '--config-file' in argv: + self.config_file = argv[argv.index('--config-file') + 1] + old_dir = Path(self.getStartDirOld()) + new_dir = Path(self.getStartDir()) + no_migrate = '--no-migrate' in argv + silent_migrate = '--portable' in argv or '--migrate' in argv + try: + self.start_dir = self.maybeMigrate(old_dir, new_dir, no_migrate, silent_migrate) + except Exception as ex: + raise ex + + self.updatePaths() + + def updatePaths(self): + if self.config_file is None: + self.config_file = self.start_dir / 'znc.conf' + if self.config_dir is None: + self.config_dir = self.start_dir + if self.private_dir is None: + self.private_dir = self.start_dir / 'private' + if self.data_dir is None: + self.data_dir = self.start_dir / 'data' + if self.log_dir is None: + self.log_dir = self.start_dir / 'log' + + def createPaths(self): + self.start_dir.mkdir(parents=True, exist_ok=True) + self.private_dir.mkdir(parents=True, exist_ok=True) + self.data_dir.mkdir(parents=True, exist_ok=True) + self.log_dir.mkdir(parents=True, exist_ok=True) + + def checkDir(self, root): + return (root / 'znc.conf').is_file() + + def doMigrate(self, old_dir, new_dir): + raise RuntimeError('Migration not implemented yet') + + def askMigrate(self, old_dir, new_dir, silent): + if not sys.stdin.isatty(): + raise StartupError('Migration refused: non-interactive shell') + while True: + r = input(f'You have old data in `{old_dir}`. Migrate to new format to `{new_dir}`? [Y/n]') + if r.lower().startswith('n'): + raise StartupError('Migration refused') + if r.lower().startswith('y'): + return self.doMigrate(old_dir, new_dir) + + def createNewConfig(self, new_dir): + new_dir.mkdir(parents=True, exist_ok=True) + with (new_dir / 'znc.conf').open('w') as f: + f.write('# zeronet-conervancy config file') + + def maybeMigrate(self, old_dir, new_dir, no_migrate, silent_migrate): + if old_dir.exists() and new_dir.exists(): + if old_dir == new_dir: + if self.checkDir(new_dir): + return new_dir + elif no_migrate: + return StartError('Migration refused, but new directory should be migrated') + else: + return askMigrate(old_dir, new_dir, silent_migrate) + else: + if self.checkDir(new_dir): + if not no_migrate: + print("There's an old starting directory") + return new_dir + else: + raise StartupError('Bad startup directory') + elif old_dir.exists(): + if no_migrate: + self.createNewConfig(new_dir) + return new_dir + else: + return self.askMigrate(old_dir, new_dir, silent_migrate) + elif new_dir.exists(): + if self.checkDir(new_dir): + return new_dir + else: + return StartupError('Bad startup directory') + else: + self.createNewConfig(new_dir) + return new_dir + + def getStartDir(self): + """Return directory with config & data""" + if "--start-dir" in self.argv: + return self.argv[self.argv.index("--start-dir") + 1] + + here = os.path.dirname(os.path.abspath(__file__).replace("\\", "/")).rstrip('/src') + if '--portable' in self.argv or self.build_type == 'portable': + return here + + MACOSX_DIR = '~/Library/Application Support/zeronet-conservancy' + WINDOWS_DIR = '~/AppData/zeronet-conservancy' + LIBREDESKTOP_DIR = '~/.local/share/zeronet-conservancy' + if self.platform == 'source': + if platform.system() == 'Darwin': + path = MACOSX_DIR + elif platform.system() == 'Windows': + path = WINDOWS_DIR + else: + path = LIBREDESKTOP_DIR + elif self.platform == 'macosx': + path = MACOSX_DIR + elif self.platform == 'windows': + path = WINDOWS_DIR + elif self.platform == 'libredesktop': + path = LIBREDESKTOP_DIR + else: + raise RuntimeError(f'UNKNOWN PLATFORM: {self.platform}. Something must have went terribly wrong!') + return os.path.expanduser(path) + # Create command line arguments def createArguments(self): try: @@ -109,9 +243,9 @@ class Config: else: fix_float_decimals = False - config_file = self.start_dir + "/zeronet.conf" - data_dir = self.start_dir + "/data" - log_dir = self.start_dir + "/log" + config_file = self.config_file + data_dir = self.data_dir + log_dir = self.log_dir ip_local = ["127.0.0.1", "::1"] @@ -229,9 +363,11 @@ class Config: self.parser.add_argument('--batch', help="Batch mode (No interactive input for commands)", action='store_true') - self.parser.add_argument('--start-dir', help='Path of working dir for variable content (data, log, .conf)', default=self.start_dir, metavar="path") + self.parser.add_argument('--portable', action=argparse.BooleanOptionalAction) + self.parser.add_argument('--start-dir', help='Path of working dir for variable content (data, log, config)', default=self.start_dir, metavar="path") self.parser.add_argument('--config-file', help='Path of config file', default=config_file, metavar="path") self.parser.add_argument('--data-dir', help='Path of data directory', default=data_dir, metavar="path") + self.parser.add_argument('--no-migrate', help='Ignore data directories from old 0net versions', action=argparse.BooleanOptionalAction, default=False) self.parser.add_argument('--console-log-level', help='Level of logging to console', default="default", choices=["default", "DEBUG", "INFO", "ERROR", "off"]) @@ -277,7 +413,7 @@ class Config: self.parser.add_argument('--proxy', help='Socks proxy address', metavar='ip:port') self.parser.add_argument('--bind', help='Bind outgoing sockets to this address', metavar='ip') self.parser.add_argument('--bootstrap-url', help='URL of file with link to bootstrap bundle', default='https://raw.githubusercontent.com/zeronet-conservancy/zeronet-conservancy/master/bootstrap.url', type=str) - self.parser.add_argument('--disable-bootstrap', help='Disable downloading bootstrap information from clearnet', action='store_true') + self.parser.add_argument('--bootstrap', help='Enable downloading bootstrap information from clearnet', action=argparse.BooleanOptionalAction, default=True) self.parser.add_argument('--trackers', help='Bootstraping torrent trackers', default=[], metavar='protocol://address', nargs='*') self.parser.add_argument('--trackers-file', help='Load torrent trackers dynamically from a file (using Syncronite by default)', default=['{data_dir}/15CEFKBRHFfAP9rmL6hhLmHoXrrgmw4B5o/cache/1/Syncronite.html'], metavar='path', nargs='*') self.parser.add_argument('--trackers-proxy', help='Force use proxy to connect to trackers (disable, tor, ip:port)', default="disable") @@ -328,7 +464,7 @@ class Config: return self.parser def loadTrackersFile(self): - if not self.trackers_file: + if self.trackers_file is None: return None self.trackers = self.arguments.trackers[:] @@ -338,16 +474,17 @@ class Config: if trackers_file.startswith("/"): # Absolute trackers_file_path = trackers_file elif trackers_file.startswith("{data_dir}"): # Relative to data_dir - trackers_file_path = trackers_file.replace("{data_dir}", self.data_dir) - else: # Relative to zeronet.py - trackers_file_path = self.start_dir + "/" + trackers_file + trackers_file_path = trackers_file.replace('{data_dir}', str(self.data_dir)) + else: + # Relative to zeronet.py or something else, unsupported + raise RuntimeError(f'trackers_file should be relative to {{data_dir}} or absolute path (not {trackers_file})') for line in open(trackers_file_path): tracker = line.strip() if "://" in tracker and tracker not in self.trackers: self.trackers.append(tracker) except Exception as err: - print("Error loading trackers file: %s" % err) + print(f'Error loading trackers file: {err}') # Find arguments specified for current action def getActionArguments(self): @@ -412,6 +549,8 @@ class Config: self.parseCommandline(argv, silent) # Parse argv self.setAttributes() + self.updatePaths() + self.createPaths() if parse_config: argv = self.parseConfig(argv) # Add arguments from config file @@ -436,7 +575,7 @@ class Config: for arg in args: if arg.startswith('--') and '_' in arg: farg = arg.replace('_', '-') - print(f'WARNING: using deprecated flag in command line: {arg} should be {farg}') + print(f'[bold red]WARNING: using deprecated flag in command line: {arg} should be {farg}[/bold red]') print('Support for deprecated flags might be removed in the future') else: farg = arg @@ -473,9 +612,6 @@ class Config: def parseConfig(self, argv): argv = self.fixArgs(argv) - # Find config file path from parameters - if "--config-file" in argv: - self.config_file = argv[argv.index("--config-file") + 1] # Load config file if os.path.isfile(self.config_file): config = configparser.RawConfigParser(allow_no_value=True, strict=False) @@ -518,7 +654,7 @@ class Config: val = val[:] if key in ("data_dir", "log_dir", "start_dir", "openssl_bin_file", "openssl_lib_file"): if val: - val = val.replace("\\", "/") + val = Path(val) setattr(self, key, val) def loadPlugins(self): diff --git a/src/Content/ContentDb.py b/src/Content/ContentDb.py index f284581e..0abd3658 100644 --- a/src/Content/ContentDb.py +++ b/src/Content/ContentDb.py @@ -153,7 +153,7 @@ content_dbs = {} def getContentDb(path=None): if not path: - path = "%s/content.db" % config.data_dir + path = config.start_dir / 'content.db' if path not in content_dbs: content_dbs[path] = ContentDb(path) content_dbs[path].init() diff --git a/src/Crypt/CryptConnection.py b/src/Crypt/CryptConnection.py index ebbc6295..c7f3ea5b 100644 --- a/src/Crypt/CryptConnection.py +++ b/src/Crypt/CryptConnection.py @@ -24,20 +24,20 @@ class CryptConnectionManager: self.context_server = None self.openssl_conf_template = "src/lib/openssl/openssl.cnf" - self.openssl_conf = config.data_dir + "/openssl.cnf" + self.openssl_conf = config.private_dir / "openssl.cnf" self.openssl_env = { "OPENSSL_CONF": self.openssl_conf, - "RANDFILE": config.data_dir + "/openssl-rand.tmp" + "RANDFILE": config.private_dir / "openssl-rand.tmp" } self.crypt_supported = [] # Supported cryptos - self.cacert_pem = config.data_dir + "/cacert-rsa.pem" - self.cakey_pem = config.data_dir + "/cakey-rsa.pem" - self.cert_pem = config.data_dir + "/cert-rsa.pem" - self.cert_csr = config.data_dir + "/cert-rsa.csr" - self.key_pem = config.data_dir + "/key-rsa.pem" + self.cacert_pem = config.private_dir / "cacert-rsa.pem" + self.cakey_pem = config.private_dir / "cakey-rsa.pem" + self.cert_pem = config.private_dir / "cert-rsa.pem" + self.cert_csr = config.private_dir / "cert-rsa.csr" + self.key_pem = config.private_dir / "key-rsa.pem" self.log = logging.getLogger("CryptConnectionManager") self.log.debug("Version: %s" % ssl.OPENSSL_VERSION) @@ -105,8 +105,8 @@ class CryptConnectionManager: if config.keep_ssl_cert: return False for file_name in ["cert-rsa.pem", "key-rsa.pem", "cacert-rsa.pem", "cakey-rsa.pem", "cacert-rsa.srl", "cert-rsa.csr", "openssl-rand.tmp"]: - file_path = "%s/%s" % (config.data_dir, file_name) - if os.path.isfile(file_path): + file_path = config.data_dir / file_name + if file_path.is_file(): os.unlink(file_path) # Load and create cert files is necessary diff --git a/src/Debug/DebugReloader.py b/src/Debug/DebugReloader.py index 482c7921..be9b4d8c 100644 --- a/src/Debug/DebugReloader.py +++ b/src/Debug/DebugReloader.py @@ -21,7 +21,7 @@ else: class DebugReloader: def __init__(self, paths=None): if not paths: - paths = ["src", "plugins", config.data_dir + "/__plugins__"] + paths = ["src", "plugins"] self.log = logging.getLogger("DebugReloader") self.last_chaged = 0 self.callbacks = [] diff --git a/src/Plugin/PluginManager.py b/src/Plugin/PluginManager.py index ab0940e8..82db4cfd 100644 --- a/src/Plugin/PluginManager.py +++ b/src/Plugin/PluginManager.py @@ -25,7 +25,7 @@ class PluginManager: self.after_load = [] # Execute functions after loaded plugins self.function_flags = {} # Flag function for permissions self.reloading = False - self.config_path = config.data_dir + "/plugins.json" + self.config_path = config.config_dir / 'plugins.json' self.loadConfig() self.config.setdefault("builtin", {}) diff --git a/src/Site/Site.py b/src/Site/Site.py index ad0e3ca2..becc7d3e 100644 --- a/src/Site/Site.py +++ b/src/Site/Site.py @@ -88,9 +88,10 @@ class Site(object): def loadSettings(self, settings=None): if not settings: try: - settings = json.load(open(f'{config.data_dir}/sites.json')).get(self.address) + with (config.private_dir / 'sites.json').open() as f: + settings = json.load(f).get(self.address) except Exception as err: - logging.error(f'Error loading {config.data_dir}/sites.json: {err}') + logging.error(f'Error loading {config.private_dir}/sites.json: {err}') settings = {} if settings: self.settings = settings diff --git a/src/Site/SiteManager.py b/src/Site/SiteManager.py index 78c20f86..0fb3b060 100644 --- a/src/Site/SiteManager.py +++ b/src/Site/SiteManager.py @@ -38,7 +38,7 @@ class SiteManager(object): load_s = time.time() # Load new adresses try: - json_path = f"{config.data_dir}/sites.json" + json_path = config.private_dir / 'sites.json' data = json.load(open(json_path)) except Exception as err: self.log.error(f"Unable to load {json_path}: {err}") @@ -48,7 +48,7 @@ class SiteManager(object): for address, settings in data.items(): if address not in self.sites: - if os.path.isfile("%s/%s/content.json" % (config.data_dir, address)): + if (config.data_dir / address / 'content.json').is_file(): # Root content.json exists, try load site s = time.time() try: @@ -121,7 +121,7 @@ class SiteManager(object): s = time.time() if data: - helper.atomicWrite("%s/sites.json" % config.data_dir, helper.jsonDumps(data).encode("utf8")) + helper.atomicWrite(config.private_dir / 'sites.json', helper.jsonDumps(data).encode("utf8")) else: self.log.debug("Save error: No data") time_write = time.time() - s diff --git a/src/Site/SiteStorage.py b/src/Site/SiteStorage.py index be8f88e9..7249ad34 100644 --- a/src/Site/SiteStorage.py +++ b/src/Site/SiteStorage.py @@ -29,7 +29,7 @@ thread_pool_fs_batch = ThreadPool.ThreadPool(1, name="FS batch") class SiteStorage(object): def __init__(self, site, allow_create=True): self.site = site - self.directory = f'{config.data_dir}/{self.site.address}' # Site data diretory + self.directory = config.data_dir / self.site.address # Site data diretory self.allowed_dir = os.path.abspath(self.directory) # Only serve file within this dir self.log = site.log self.db = None # Db class diff --git a/src/Ui/UiRequest.py b/src/Ui/UiRequest.py index 70470dba..1f18a6c3 100644 --- a/src/Ui/UiRequest.py +++ b/src/Ui/UiRequest.py @@ -786,7 +786,7 @@ class UiRequest: address = path_parts["address"] - file_path = "%s/%s/%s" % (config.data_dir, address, path_parts["inner_path"]) + file_path = config.data_dir / address / path_parts['inner_path'] if (config.debug or config.merge_media) and file_path.split("/")[-1].startswith("all."): # If debugging merge *.css to all.css and *.js to all.js diff --git a/src/User/User.py b/src/User/User.py index dbcfc56f..bbf18f07 100644 --- a/src/User/User.py +++ b/src/User/User.py @@ -35,8 +35,9 @@ class User(object): # Save to data/users.json @util.Noparallel(queue=True, ignore_class=True) def save(self): + users_json = config.private_dir / 'users.json' s = time.time() - users = json.load(open("%s/users.json" % config.data_dir)) + users = json.load(open(users_json)) if self.master_address not in users: users[self.master_address] = {} # Create if not exist user_data = users[self.master_address] @@ -45,7 +46,7 @@ class User(object): user_data["sites"] = self.sites user_data["certs"] = self.certs user_data["settings"] = self.settings - helper.atomicWrite("%s/users.json" % config.data_dir, helper.jsonDumps(users).encode("utf8")) + helper.atomicWrite(users_json, helper.jsonDumps(users).encode("utf8")) self.log.debug("Saved in %.3fs" % (time.time() - s)) self.delayed_save_thread = None diff --git a/src/User/UserManager.py b/src/User/UserManager.py index 067734a6..b8e49664 100644 --- a/src/User/UserManager.py +++ b/src/User/UserManager.py @@ -15,7 +15,7 @@ class UserManager(object): self.users = {} self.log = logging.getLogger("UserManager") - # Load all user from data/users.json + # Load all user from users.json def load(self): if not self.users: self.users = {} @@ -25,7 +25,7 @@ class UserManager(object): s = time.time() # Load new users try: - json_path = "%s/users.json" % config.data_dir + json_path = config.private_dir / 'users.json' data = json.load(open(json_path)) except Exception as err: raise Exception("Unable to load %s: %s" % (json_path, err)) @@ -57,7 +57,7 @@ class UserManager(object): user.saveDelayed() return user - # List all users from data/users.json + # List all users # Return: {"usermasteraddr": User} def list(self): if self.users == {}: # Not loaded yet diff --git a/src/main.py b/src/main.py index b7fd6e8b..c9f6b3d0 100644 --- a/src/main.py +++ b/src/main.py @@ -4,6 +4,9 @@ import stat import time import logging from util.compat import * +from pathlib import Path + +from rich import print startup_errors = [] def startupError(msg): @@ -12,13 +15,6 @@ def startupError(msg): # Third party modules import gevent -if gevent.version_info.major <= 1: # Workaround for random crash when libuv used with threads - try: - if "libev" not in str(gevent.config.loop): - gevent.config.loop = "libev-cext" - except Exception as err: - startupError("Unable to switch gevent loop to libev: %s" % err) - import gevent.monkey gevent.monkey.patch_all(thread=False, subprocess=False) @@ -33,14 +29,12 @@ def load_config(): # Config parse failed completely, show the help screen and exit config.parse() -load_config() - def importBundle(bundle): from zipfile import ZipFile from Crypt.CryptBitcoin import isValidAddress import json - sites_json_path = f"{config.data_dir}/sites.json" + sites_json_path = config.private_dir / 'sites.json' try: with open(sites_json_path) as f: sites = json.load(f) @@ -58,31 +52,36 @@ def importBundle(bundle): map(lambda f: removeprefix(f, prefix).split('/')[0], all_files)))) for d in top_2: if isValidAddress(d): - logging.info(f'unpack {d} into {config.data_dir}') + print(f'Unpacking {d} into {config.data_dir}') for fname in filter(lambda f: f.startswith(prefix+d) and not f.endswith('/'), all_files): - tgt = config.data_dir + '/' + removeprefix(fname, prefix) - logging.info(f'-- {fname} --> {tgt}') + tgt = removeprefix(fname, prefix) + print(f'-- {fname} --> {tgt}') info = zf.getinfo(fname) info.filename = tgt - zf.extract(info) + zf.extract(info, path=config.data_dir) logging.info(f'add site {d}') sites[d] = {} else: - logging.info(f'Warning: unknown file in a bundle: {prefix+d}') + print(f'Warning: unknown file in a bundle: {prefix+d}') with open(sites_json_path, 'w') as f: json.dump(sites, f) def init_dirs(): - data_dir = config.data_dir - has_data_dir = os.path.isdir(data_dir) - need_bootstrap = not config.disable_bootstrap and (not has_data_dir or not os.path.isfile(f'{data_dir}/sites.json')) and not config.offline + data_dir = Path(config.data_dir) + private_dir = Path(config.private_dir) + need_bootstrap = (config.bootstrap + and not config.offline + and (not data_dir.is_dir() or not (private_dir / 'sites.json').is_file())) - if not has_data_dir: - os.mkdir(data_dir) - try: - os.chmod(data_dir, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) - except Exception as err: - startupError(f"Can't change permission of {data_dir}: {err}") + # old_users_json = data_dir / 'users.json' + # if old_users_json.is_file(): + # print('Migrating existing users.json file to private/') + # old_sites_json = data_dir / 'sites.json' + # if old_sites_json.is_file(): + # print('Migrating existing sites.json file to private/') + + if not data_dir.is_dir(): + data_dir.mkdir(parents=True, exist_ok=True) if need_bootstrap: import requests @@ -99,626 +98,105 @@ def init_dirs(): startupError(f"Cannot load boostrap bundle (response status: {response.status_code})") importBundle(BytesIO(response.content)) - sites_json = f"{data_dir}/sites.json" + sites_json = private_dir / 'sites.json' if not os.path.isfile(sites_json): with open(sites_json, "w") as f: f.write("{}") - users_json = f"{data_dir}/users.json" + users_json = private_dir / 'users.json' if not os.path.isfile(users_json): with open(users_json, "w") as f: f.write("{}") -# TODO: GET RID OF TOP-LEVEL CODE!!! -config.initConsoleLogger() - -try: - init_dirs() -except: - import traceback as tb - print(tb.format_exc()) - # at least make sure to print help if we're otherwise so helpless - config.parser.print_help() - sys.exit(1) - -if config.action == "main": - from util import helper - try: - lock = helper.openLocked(f"{config.data_dir}/lock.pid", "w") - lock.write(f"{os.getpid()}") - except BlockingIOError as err: - startupError(f"Can't open lock file, your 0net client is probably already running, exiting... ({err})") - proc = helper.openBrowser(config.open_browser) - r = proc.wait() - sys.exit(r) - -config.initLogging(console_logging=False) - -# Debug dependent configuration -from Debug import DebugHook -from Plugin import PluginManager - def load_plugins(): + from Plugin import PluginManager PluginManager.plugin_manager.loadPlugins() config.loadPlugins() config.parse() # Parse again to add plugin configuration options -load_plugins() +def init(): + load_config() + config.initConsoleLogger() -# Log current config -logging.debug("Config: %s" % config) - -# Modify stack size on special hardwares -if config.stack_size: - import threading - threading.stack_size(config.stack_size) - -# Use pure-python implementation of msgpack to save CPU -if config.msgpack_purepython: - os.environ["MSGPACK_PUREPYTHON"] = "True" - -# Fix console encoding on Windows -# TODO: check if this is still required -if sys.platform.startswith("win"): - import subprocess try: - chcp_res = subprocess.check_output("chcp 65001", shell=True).decode(errors="ignore").strip() - logging.debug("Changed console encoding to utf8: %s" % chcp_res) - except Exception as err: - logging.error("Error changing console encoding to utf8: %s" % err) + init_dirs() + except: + import traceback as tb + print(tb.format_exc()) + # at least make sure to print help if we're otherwise so helpless + # config.parser.print_help() + sys.exit(1) -# Socket monkey patch -if config.proxy: - from util import SocksProxy - import urllib.request - logging.info("Patching sockets to socks proxy: %s" % config.proxy) - if config.fileserver_ip == "*": - config.fileserver_ip = '127.0.0.1' # Do not accept connections anywhere but localhost - config.disable_udp = True # UDP not supported currently with proxy - SocksProxy.monkeyPatch(*config.proxy.split(":")) -elif config.tor == "always": - from util import SocksProxy - import urllib.request - logging.info("Patching sockets to tor socks proxy: %s" % config.tor_proxy) - if config.fileserver_ip == "*": - config.fileserver_ip = '127.0.0.1' # Do not accept connections anywhere but localhost - SocksProxy.monkeyPatch(*config.tor_proxy_split()) - config.disable_udp = True -elif config.bind: - bind = config.bind - if ":" not in config.bind: - bind += ":0" - from util import helper - helper.socketBindMonkeyPatch(*bind.split(":")) + if config.action == "main": + from util import helper + try: + lock = helper.openLocked(config.start_dir / 'lock.pid', "w") + lock.write(f"{os.getpid()}") + except BlockingIOError as err: + startupError(f"Can't open lock file, your 0net client is probably already running, exiting... ({err})") + proc = helper.openBrowser(config.open_browser) + r = proc.wait() + sys.exit(r) -# -- Actions -- + config.initLogging(console_logging=False) + # Debug dependent configuration + from Debug import DebugHook -@PluginManager.acceptPlugins -class Actions: - def call(self, function_name, kwargs): - logging.info(f'zeronet-conservancy {config.version_full} on Python {sys.version} Gevent {gevent.__version__}') + load_plugins() - func = getattr(self, function_name, None) - back = func(**kwargs) - if back: - print(back) - - def ipythonThread(self): - import IPython - IPython.embed() - self.gevent_quit.set() - - # Default action: Start serving UiServer and FileServer - def main(self): - global ui_server, file_server - from File import FileServer - from Ui import UiServer - logging.info("Creating FileServer....") - file_server = FileServer() - logging.info("Creating UiServer....") - ui_server = UiServer() - file_server.ui_server = ui_server - - for startup_error in startup_errors: - logging.error("Startup error: %s" % startup_error) - - logging.info("Removing old SSL certs...") - from Crypt import CryptConnection - CryptConnection.manager.removeCerts() - - logging.info("Starting servers....") + # Log current config + logging.debug("Config: %s" % config) + # Modify stack size on special hardwares + if config.stack_size: import threading - self.gevent_quit = threading.Event() - launched_greenlets = [gevent.spawn(ui_server.start), gevent.spawn(file_server.start), gevent.spawn(ui_server.startSiteServer)] + threading.stack_size(config.stack_size) - # if --repl, start ipython thread - # FIXME: Unfortunately this leads to exceptions on exit so use with care - if config.repl: - threading.Thread(target=self.ipythonThread).start() + # Use pure-python implementation of msgpack to save CPU + if config.msgpack_purepython: + os.environ["MSGPACK_PUREPYTHON"] = "True" - stopped = 0 - # Process all greenlets in main thread - while not self.gevent_quit.is_set() and stopped < len(launched_greenlets): - stopped += len(gevent.joinall(launched_greenlets, timeout=1)) - - # Exited due to repl, so must kill greenlets - if stopped < len(launched_greenlets): - gevent.killall(launched_greenlets, exception=KeyboardInterrupt) - - logging.info("All server stopped") - - # Site commands - - def siteCreate(self, use_master_seed=True): - logging.info("Generating new privatekey (use_master_seed: %s)..." % config.use_master_seed) - from Crypt import CryptBitcoin - if use_master_seed: - from User import UserManager - user = UserManager.user_manager.get() - if not user: - user = UserManager.user_manager.create() - address, address_index, site_data = user.getNewSiteData() - privatekey = site_data["privatekey"] - logging.info("Generated using master seed from users.json, site index: %s" % address_index) - else: - privatekey = CryptBitcoin.newPrivatekey() - address = CryptBitcoin.privatekeyToAddress(privatekey) - logging.info("----------------------------------------------------------------------") - logging.info("Site private key: %s" % privatekey) - logging.info(" !!! ^ Save it now, required to modify the site ^ !!!") - logging.info("Site address: %s" % address) - logging.info("----------------------------------------------------------------------") - - while True and not config.batch and not use_master_seed: - if input("? Have you secured your private key? (yes, no) > ").lower() == "yes": - break - else: - logging.info("Please, secure it now, you going to need it to modify your site!") - - logging.info("Creating directory structure...") - from Site.Site import Site - from Site import SiteManager - SiteManager.site_manager.load() - - os.mkdir("%s/%s" % (config.data_dir, address)) - open("%s/%s/index.html" % (config.data_dir, address), "w").write("Hello %s!" % address) - - logging.info("Creating content.json...") - site = Site(address) - extend = {"postmessage_nonce_security": True} - if use_master_seed: - extend["address_index"] = address_index - - site.content_manager.sign(privatekey=privatekey, extend=extend) - site.settings["own"] = True - site.saveSettings() - - logging.info("Site created!") - - def siteSign(self, address, privatekey=None, inner_path="content.json", publish=False, remove_missing_optional=False): - from Site.Site import Site - from Site import SiteManager - from Debug import Debug - SiteManager.site_manager.load() - logging.info("Signing site: %s..." % address) - site = Site(address, allow_create=False) - - if not privatekey: # If no privatekey defined - from User import UserManager - user = UserManager.user_manager.get() - if user: - site_data = user.getSiteData(address) - privatekey = site_data.get("privatekey") - else: - privatekey = None - if not privatekey: - # Not found in users.json, ask from console - import getpass - privatekey = getpass.getpass("Private key (input hidden):") - # inner_path can be either relative to site directory or absolute/relative path - if os.path.isabs(inner_path): - full_path = os.path.abspath(inner_path) - else: - full_path = os.path.abspath(config.working_dir + '/' + inner_path) - print(full_path) - if os.path.isfile(full_path): - if address in full_path: - # assuming site address is unique, keep only path after it - inner_path = full_path.split(address+'/')[1] - else: - # oops, file that we found seems to be rogue, so reverting to old behaviour - logging.warning(f'using {inner_path} relative to site directory') + # Fix console encoding on Windows + # TODO: check if this is still required + if sys.platform.startswith("win"): + import subprocess try: - succ = site.content_manager.sign( - inner_path=inner_path, privatekey=privatekey, - update_changed_files=True, remove_missing_optional=remove_missing_optional - ) + chcp_res = subprocess.check_output("chcp 65001", shell=True).decode(errors="ignore").strip() + logging.debug("Changed console encoding to utf8: %s" % chcp_res) except Exception as err: - logging.error("Sign error: %s" % Debug.formatException(err)) - succ = False - if succ and publish: - self.sitePublish(address, inner_path=inner_path) + logging.error("Error changing console encoding to utf8: %s" % err) - def siteVerify(self, address): - import time - from Site.Site import Site - from Site import SiteManager - SiteManager.site_manager.load() + # Socket monkey patch + if config.proxy: + from util import SocksProxy + import urllib.request + logging.info("Patching sockets to socks proxy: %s" % config.proxy) + if config.fileserver_ip == "*": + config.fileserver_ip = '127.0.0.1' # Do not accept connections anywhere but localhost + config.disable_udp = True # UDP not supported currently with proxy + SocksProxy.monkeyPatch(*config.proxy.split(":")) + elif config.tor == "always": + from util import SocksProxy + import urllib.request + logging.info("Patching sockets to tor socks proxy: %s" % config.tor_proxy) + if config.fileserver_ip == "*": + config.fileserver_ip = '127.0.0.1' # Do not accept connections anywhere but localhost + SocksProxy.monkeyPatch(*config.tor_proxy_split()) + config.disable_udp = True + elif config.bind: + bind = config.bind + if ":" not in config.bind: + bind += ":0" + from util import helper + helper.socketBindMonkeyPatch(*bind.split(":")) - s = time.time() - logging.info("Verifing site: %s..." % address) - site = Site(address) - bad_files = [] - - for content_inner_path in site.content_manager.contents: - s = time.time() - logging.info("Verifing %s signature..." % content_inner_path) - error = None - try: - file_correct = site.content_manager.verifyFile( - content_inner_path, site.storage.open(content_inner_path, "rb"), ignore_same=False - ) - except Exception as err: - file_correct = False - error = err - - if file_correct is True: - logging.info("[OK] %s (Done in %.3fs)" % (content_inner_path, time.time() - s)) - else: - logging.error("[ERROR] %s: invalid file: %s!" % (content_inner_path, error)) - input("Continue?") - bad_files += content_inner_path - - logging.info("Verifying site files...") - bad_files += site.storage.verifyFiles()["bad_files"] - if not bad_files: - logging.info("[OK] All file sha512sum matches! (%.3fs)" % (time.time() - s)) - else: - logging.error("[ERROR] Error during verifying site files!") - - def dbRebuild(self, address): - from Site.Site import Site - from Site import SiteManager - SiteManager.site_manager.load() - - logging.info("Rebuilding site sql cache: %s..." % address) - site = SiteManager.site_manager.get(address) - s = time.time() - try: - site.storage.rebuildDb() - logging.info("Done in %.3fs" % (time.time() - s)) - except Exception as err: - logging.error(err) - - def dbQuery(self, address, query): - from Site.Site import Site - from Site import SiteManager - SiteManager.site_manager.load() - - import json - site = Site(address) - result = [] - for row in site.storage.query(query): - result.append(dict(row)) - print(json.dumps(result, indent=4)) - - def siteAnnounce(self, address): - from Site.Site import Site - from Site import SiteManager - SiteManager.site_manager.load() - - logging.info("Opening a simple connection server") - global file_server - from File import FileServer - file_server = FileServer("127.0.0.1", 1234) - file_server.start() - - logging.info("Announcing site %s to tracker..." % address) - site = Site(address) - - s = time.time() - site.announce() - print("Response time: %.3fs" % (time.time() - s)) - print(site.peers) - - def siteDownload(self, address): - from Site.Site import Site - from Site import SiteManager - SiteManager.site_manager.load() - - logging.info("Opening a simple connection server") - global file_server - from File import FileServer - file_server = FileServer("127.0.0.1", 1234) - file_server_thread = gevent.spawn(file_server.start, check_sites=False) - - site = Site(address) - - on_completed = gevent.event.AsyncResult() - - def onComplete(evt): - evt.set(True) - - site.onComplete.once(lambda: onComplete(on_completed)) - print("Announcing...") - site.announce() - - s = time.time() - print("Downloading...") - site.downloadContent("content.json", check_modifications=True) - - print("Downloaded in %.3fs" % (time.time()-s)) - - def siteNeedFile(self, address, inner_path): - from Site.Site import Site - from Site import SiteManager - SiteManager.site_manager.load() - - def checker(): - while 1: - s = time.time() - time.sleep(1) - print("Switch time:", time.time() - s) - gevent.spawn(checker) - - logging.info("Opening a simple connection server") - global file_server - from File import FileServer - file_server = FileServer("127.0.0.1", 1234) - file_server_thread = gevent.spawn(file_server.start, check_sites=False) - - site = Site(address) - site.announce() - print(site.needFile(inner_path, update=True)) - - def siteCmd(self, address, cmd, parameters): - import json - from Site import SiteManager - - site = SiteManager.site_manager.get(address) - - if not site: - logging.error("Site not found: %s" % address) - return None - - ws = self.getWebsocket(site) - - ws.send(json.dumps({"cmd": cmd, "params": parameters, "id": 1})) - res_raw = ws.recv() - - try: - res = json.loads(res_raw) - except Exception as err: - return {"error": "Invalid result: %s" % err, "res_raw": res_raw} - - if "result" in res: - return res["result"] - else: - return res - - def importBundle(self, bundle): - importBundle(bundle) - - def getWebsocket(self, site): - import websocket - - ws_address = "ws://%s:%s/Websocket?wrapper_key=%s" % (config.ui_ip, config.ui_port, site.settings["wrapper_key"]) - logging.info("Connecting to %s" % ws_address) - ws = websocket.create_connection(ws_address) - return ws - - def sitePublish(self, address, peer_ip=None, peer_port=15441, inner_path="content.json", recursive=False): - from Site import SiteManager - logging.info("Loading site...") - site = SiteManager.site_manager.get(address) - site.settings["serving"] = True # Serving the site even if its disabled - - if not recursive: - inner_paths = [inner_path] - else: - inner_paths = list(site.content_manager.contents.keys()) - - try: - ws = self.getWebsocket(site) - - except Exception as err: - self.sitePublishFallback(site, peer_ip, peer_port, inner_paths, err) - - else: - logging.info("Sending siteReload") - self.siteCmd(address, "siteReload", inner_path) - - for inner_path in inner_paths: - logging.info(f"Sending sitePublish for {inner_path}") - self.siteCmd(address, "sitePublish", {"inner_path": inner_path, "sign": False}) - logging.info("Done.") - ws.close() - - def sitePublishFallback(self, site, peer_ip, peer_port, inner_paths, err): - if err is not None: - logging.info(f"Can't connect to local websocket client: {err}") - logging.info("Publish using fallback mechanism. " - "Note that there might be not enough time for peer discovery, " - "but you can specify target peer on command line.") - logging.info("Creating FileServer....") - file_server_thread = gevent.spawn(file_server.start, check_sites=False) # Dont check every site integrity - time.sleep(0.001) - - # Started fileserver - file_server.portCheck() - if peer_ip: # Announce ip specificed - site.addPeer(peer_ip, peer_port) - else: # Just ask the tracker - logging.info("Gathering peers from tracker") - site.announce() # Gather peers - - for inner_path in inner_paths: - published = site.publish(5, inner_path) # Push to peers - - if published > 0: - time.sleep(3) - logging.info("Serving files (max 60s)...") - gevent.joinall([file_server_thread], timeout=60) - logging.info("Done.") - else: - logging.info("No peers found, sitePublish command only works if you already have visitors serving your site") - - # Crypto commands - def cryptPrivatekeyToAddress(self, privatekey=None): - from Crypt import CryptBitcoin - if not privatekey: # If no privatekey in args then ask it now - import getpass - privatekey = getpass.getpass("Private key (input hidden):") - - print(CryptBitcoin.privatekeyToAddress(privatekey)) - - def cryptSign(self, message, privatekey): - from Crypt import CryptBitcoin - print(CryptBitcoin.sign(message, privatekey)) - - def cryptVerify(self, message, sign, address): - from Crypt import CryptBitcoin - print(CryptBitcoin.verify(message, address, sign)) - - def cryptGetPrivatekey(self, master_seed, site_address_index=None): - from Crypt import CryptBitcoin - if len(master_seed) != 64: - logging.error("Error: Invalid master seed length: %s (required: 64)" % len(master_seed)) - return False - privatekey = CryptBitcoin.hdPrivatekey(master_seed, site_address_index) - print("Requested private key: %s" % privatekey) - - # Peer - def peerPing(self, peer_ip, peer_port=None): - if not peer_port: - peer_port = 15441 - logging.info("Opening a simple connection server") - global file_server - from Connection import ConnectionServer - file_server = ConnectionServer("127.0.0.1", 1234) - file_server.start(check_connections=False) - from Crypt import CryptConnection - CryptConnection.manager.loadCerts() - - from Peer import Peer - logging.info("Pinging 5 times peer: %s:%s..." % (peer_ip, int(peer_port))) - s = time.time() - peer = Peer(peer_ip, peer_port) - peer.connect() - - if not peer.connection: - print("Error: Can't connect to peer (connection error: %s)" % peer.connection_error) - return False - if "shared_ciphers" in dir(peer.connection.sock): - print("Shared ciphers:", peer.connection.sock.shared_ciphers()) - if "cipher" in dir(peer.connection.sock): - print("Cipher:", peer.connection.sock.cipher()[0]) - if "version" in dir(peer.connection.sock): - print("TLS version:", peer.connection.sock.version()) - print("Connection time: %.3fs (connection error: %s)" % (time.time() - s, peer.connection_error)) - - for i in range(5): - ping_delay = peer.ping() - print("Response time: %.3fs" % ping_delay) - time.sleep(1) - peer.remove() - print("Reconnect test...") - peer = Peer(peer_ip, peer_port) - for i in range(5): - ping_delay = peer.ping() - print("Response time: %.3fs" % ping_delay) - time.sleep(1) - - def peerGetFile(self, peer_ip, peer_port, site, filename, benchmark=False): - logging.info("Opening a simple connection server") - global file_server - from Connection import ConnectionServer - file_server = ConnectionServer("127.0.0.1", 1234) - file_server.start(check_connections=False) - from Crypt import CryptConnection - CryptConnection.manager.loadCerts() - - from Peer import Peer - logging.info("Getting %s/%s from peer: %s:%s..." % (site, filename, peer_ip, peer_port)) - peer = Peer(peer_ip, peer_port) - s = time.time() - if benchmark: - for i in range(10): - peer.getFile(site, filename), - print("Response time: %.3fs" % (time.time() - s)) - input("Check memory") - else: - print(peer.getFile(site, filename).read()) - - def peerCmd(self, peer_ip, peer_port, cmd, parameters): - logging.info("Opening a simple connection server") - global file_server - from Connection import ConnectionServer - file_server = ConnectionServer() - file_server.start(check_connections=False) - from Crypt import CryptConnection - CryptConnection.manager.loadCerts() - - from Peer import Peer - peer = Peer(peer_ip, peer_port) - - import json - if parameters: - parameters = json.loads(parameters.replace("'", '"')) - else: - parameters = {} - try: - res = peer.request(cmd, parameters) - print(json.dumps(res, indent=2, ensure_ascii=False)) - except Exception as err: - print("Unknown response (%s): %s" % (err, res)) - - def getConfig(self): - import json - print(json.dumps(config.getServerInfo(), indent=2, ensure_ascii=False)) - - def test(self, test_name, *args, **kwargs): - import types - def funcToName(func_name): - test_name = func_name.replace("test", "") - return test_name[0].lower() + test_name[1:] - - test_names = [funcToName(name) for name in dir(self) if name.startswith("test") and name != "test"] - if not test_name: - # No test specificed, list tests - print("\nNo test specified, possible tests:") - for test_name in test_names: - func_name = "test" + test_name[0].upper() + test_name[1:] - func = getattr(self, func_name) - if func.__doc__: - print("- %s: %s" % (test_name, func.__doc__.strip())) - else: - print("- %s" % test_name) - return None - - # Run tests - func_name = "test" + test_name[0].upper() + test_name[1:] - if hasattr(self, func_name): - func = getattr(self, func_name) - print("- Running test: %s" % test_name, end="") - s = time.time() - ret = func(*args, **kwargs) - if type(ret) is types.GeneratorType: - for progress in ret: - print(progress, end="") - sys.stdout.flush() - print("\n* Test %s done in %.3fs" % (test_name, time.time() - s)) - else: - print("Unknown test: %r (choose from: %s)" % ( - test_name, test_names - )) +init() +from Actions import Actions actions = Actions() + # Starts here when running zeronet.py - - def start(): # Call function action_kwargs = config.getActionArguments() diff --git a/src/util/Git.py b/src/util/Git.py index 7b60d396..ef633abe 100644 --- a/src/util/Git.py +++ b/src/util/Git.py @@ -48,7 +48,7 @@ def _gitted(f): return lambda *args, **kwargs: None @_gitted -def commit() -> str: +def commit() -> Optional[str]: """Returns git revision, possibly suffixed with -dirty""" dirty = '-dirty' if _repo.is_dirty() else '' return f'{_repo.head.commit}{dirty}' diff --git a/src/util/argparseCompat.py b/src/util/argparseCompat.py new file mode 100644 index 00000000..5d2a7f16 --- /dev/null +++ b/src/util/argparseCompat.py @@ -0,0 +1,114 @@ +# This code is taken from CPython Lib/argparse.py and contains BooleanOptionalAction +# for use in py<3.9 + +# Author: Steven J. Bethard . +# New maintainer as of 29 August 2019: Raymond Hettinger + +# Copyright © 2001-2024 Python Software Foundation. All rights reserved. +# PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +# -------------------------------------------- +# +# 1. This LICENSE AGREEMENT is between the Python Software Foundation +# ("PSF"), and the Individual or Organization ("Licensee") accessing and +# otherwise using this software ("Python") in source or binary form and +# its associated documentation. +# +# 2. Subject to the terms and conditions of this License Agreement, PSF hereby +# grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +# analyze, test, perform and/or display publicly, prepare derivative works, +# distribute, and otherwise use Python alone or in any derivative version, +# provided, however, that PSF's License Agreement and PSF's notice of copyright, +# i.e., "Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved" +# are retained in Python alone or in any derivative version prepared by Licensee. +# +# 3. In the event Licensee prepares a derivative work that is based on +# or incorporates Python or any part thereof, and wants to make +# the derivative work available to others as provided herein, then +# Licensee hereby agrees to include in any such work a brief summary of +# the changes made to Python. +# +# 4. PSF is making Python available to Licensee on an "AS IS" +# basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +# IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +# DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +# FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +# INFRINGE ANY THIRD PARTY RIGHTS. +# +# 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +# FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +# A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +# OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. +# +# 6. This License Agreement will automatically terminate upon a material +# breach of its terms and conditions. +# +# 7. Nothing in this License Agreement shall be deemed to create any +# relationship of agency, partnership, or joint venture between PSF and +# Licensee. This License Agreement does not grant permission to use PSF +# trademarks or trade name in a trademark sense to endorse or promote +# products or services of Licensee, or any third party. +# +# 8. By copying, installing or otherwise using Python, Licensee +# agrees to be bound by the terms and conditions of this License +# Agreement. + +from argparse import Action + +class BooleanOptionalAction(Action): + def __init__(self, + option_strings, + dest, + default=None, + type=_deprecated_default, + choices=_deprecated_default, + required=False, + help=None, + metavar=_deprecated_default, + deprecated=False): + + _option_strings = [] + for option_string in option_strings: + _option_strings.append(option_string) + + if option_string.startswith('--'): + option_string = '--no-' + option_string[2:] + _option_strings.append(option_string) + + # We need `_deprecated` special value to ban explicit arguments that + # match default value. Like: + # parser.add_argument('-f', action=BooleanOptionalAction, type=int) + for field_name in ('type', 'choices', 'metavar'): + if locals()[field_name] is not _deprecated_default: + import warnings + warnings._deprecated( + field_name, + "{name!r} is deprecated as of Python 3.12 and will be " + "removed in Python {remove}.", + remove=(3, 14)) + + if type is _deprecated_default: + type = None + if choices is _deprecated_default: + choices = None + if metavar is _deprecated_default: + metavar = None + + super().__init__( + option_strings=_option_strings, + dest=dest, + nargs=0, + default=default, + type=type, + choices=choices, + required=required, + help=help, + metavar=metavar, + deprecated=deprecated) + + + def __call__(self, parser, namespace, values, option_string=None): + if option_string in self.option_strings: + setattr(namespace, self.dest, not option_string.startswith('--no-')) + + def format_usage(self): + return ' | '.join(self.option_strings) diff --git a/src/util/compat.py b/src/util/compat.py index f41e67b2..1867dad0 100644 --- a/src/util/compat.py +++ b/src/util/compat.py @@ -14,3 +14,9 @@ else: return s.removeprefix(prefix) def removesuffix(s, suffix, /): return s.removesuffix(suffix) + +import argparse + +if not hasattr(argparse, 'BooleanOptionalAction'): + from .argparseCompat import BooleanOptionalAction + argparse.BooleanOptionalAction = BooleanOptionalAction diff --git a/src/util/helper.py b/src/util/helper.py index 8c7c6fff..97ae497d 100644 --- a/src/util/helper.py +++ b/src/util/helper.py @@ -16,17 +16,17 @@ from Config import config def atomicWrite(dest, content, mode="wb"): try: - with open(dest + "-tmpnew", mode) as f: + with open(f'{dest}-tmpnew', mode) as f: f.write(content) f.flush() os.fsync(f.fileno()) - if os.path.isfile(dest + "-tmpold"): # Previous incomplete write - os.rename(dest + "-tmpold", dest + "-tmpold-%s" % time.time()) + if os.path.isfile(f'{dest}-tmpold'): # Previous incomplete write + os.rename(f'{dest}-tmpold', f'{dest}-tmpold-{time.time()}') if os.path.isfile(dest): # Rename old file to -tmpold - os.rename(dest, dest + "-tmpold") - os.rename(dest + "-tmpnew", dest) - if os.path.isfile(dest + "-tmpold"): - os.unlink(dest + "-tmpold") # Remove old file + os.rename(dest, f'{dest}-tmpold') + os.rename(f'{dest}-tmpnew', dest) + if os.path.isfile(f'{dest}-tmpold'): + os.unlink(f'{dest}-tmpold') # Remove old file return True except Exception as err: from Debug import Debug @@ -34,8 +34,8 @@ def atomicWrite(dest, content, mode="wb"): "File %s write failed: %s, (%s) reverting..." % (dest, Debug.formatException(err), Debug.formatStack()) ) - if os.path.isfile(dest + "-tmpold") and not os.path.isfile(dest): - os.rename(dest + "-tmpold", dest) + if os.path.isfile(f'{dest}-tmpold') and not os.path.isfile(dest): + os.rename(f'{dest}-tmpold', dest) return False @@ -85,7 +85,7 @@ def openLocked(path, mode="wb"): def getFreeSpace(): free_space = -1 if "statvfs" in dir(os): # Unix - statvfs = os.statvfs(config.data_dir.encode("utf8")) + statvfs = os.statvfs(str(config.data_dir).encode("utf8")) free_space = statvfs.f_frsize * statvfs.f_bavail else: # Windows try: @@ -111,7 +111,7 @@ def shellquote(*args): if len(args) == 1: return '"%s"' % args[0].replace('"', "") else: - return tuple(['"%s"' % arg.replace('"', "") for arg in args]) + return tuple(['"%s"' % str(arg).replace('"', "") for arg in args]) def packPeers(peers): diff --git a/zeronet.py b/zeronet.py index bb53404f..4931b4f7 100755 --- a/zeronet.py +++ b/zeronet.py @@ -6,11 +6,18 @@ from src.Config import config # fix further imports from src dir sys.modules['Config'] = sys.modules['src.Config'] +def pyReq(): + major = sys.version_info.major + minor = sys.version_info.minor + if major < 3 or (major == 3 and minor < 8): + print("Error: Python 3.8+ is required") + sys.exit(0) + if major == 3 and minor < 11: + print(f"Python 3.11+ is recommended (you're running {sys.version})") + def launch(): '''renamed from main to avoid clashes with main module''' - if sys.version_info.major < 3: - print("Error: Python 3.x is required") - sys.exit(0) + pyReq() if '--silent' not in sys.argv: from greet import fancy_greet @@ -27,7 +34,7 @@ def launch(): except Exception as log_err: print("Failed to log error:", log_err) traceback.print_exc() - error_log_path = config.log_dir + "/error.log" + error_log_path = config.log_dir / "error.log" traceback.print_exc(file=open(error_log_path, "w")) print("---") print("Please report it: https://github.com/zeronet-conservancy/zeronet-conservancy/issues/new?template=bug-report.md")