diff --git a/MANIFEST.in b/MANIFEST.in index f4d1c078..c8a4d87c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,7 +4,8 @@ include BUILD.md include share/* include share/images/* include share/locale/* -include share/html/* +include share/templates/* +include share/static/* include install/onionshare.desktop include install/onionshare.appdata.xml include install/onionshare80.xpm diff --git a/install/pyinstaller.spec b/install/pyinstaller.spec index 6ca2fdbe..a4f1532a 100644 --- a/install/pyinstaller.spec +++ b/install/pyinstaller.spec @@ -20,7 +20,8 @@ a = Analysis( ('../share/torrc_template-windows', 'share'), ('../share/images/*', 'share/images'), ('../share/locale/*', 'share/locale'), - ('../share/html/*', 'share/html') + ('../share/templates/*', 'share/templates'), + ('../share/static/*', 'share/static') ], hiddenimports=[], hookspath=[], diff --git a/onionshare/__init__.py b/onionshare/__init__.py index 76d2b601..893d83a3 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -20,21 +20,24 @@ along with this program. If not, see . import os, sys, time, argparse, threading -from . import strings, common, web +from . import strings +from .common import Common +from .web import Web from .onion import * from .onionshare import OnionShare -from .settings import Settings def main(cwd=None): """ The main() function implements all of the logic that the command-line version of onionshare uses. """ + common = Common() + strings.load_strings(common) - print(strings._('version_string').format(common.get_version())) + print(strings._('version_string').format(common.version)) # OnionShare CLI in OSX needs to change current working directory (#132) - if common.get_platform() == 'Darwin': + if common.platform == 'Darwin': if cwd: os.chdir(cwd) @@ -44,9 +47,10 @@ def main(cwd=None): parser.add_argument('--stay-open', action='store_true', dest='stay_open', help=strings._("help_stay_open")) parser.add_argument('--shutdown-timeout', metavar='', dest='shutdown_timeout', default=0, help=strings._("help_shutdown_timeout")) parser.add_argument('--stealth', action='store_true', dest='stealth', help=strings._("help_stealth")) - parser.add_argument('--debug', action='store_true', dest='debug', help=strings._("help_debug")) + parser.add_argument('--receive', action='store_true', dest='receive', help=strings._("help_receive")) parser.add_argument('--config', metavar='config', default=False, help=strings._('help_config')) - parser.add_argument('filename', metavar='filename', nargs='+', help=strings._('help_filename')) + parser.add_argument('--debug', action='store_true', dest='debug', help=strings._("help_debug")) + parser.add_argument('filename', metavar='filename', nargs='*', help=strings._('help_filename')) args = parser.parse_args() filenames = args.filename @@ -58,32 +62,55 @@ def main(cwd=None): stay_open = bool(args.stay_open) shutdown_timeout = int(args.shutdown_timeout) stealth = bool(args.stealth) + receive = bool(args.receive) config = args.config - # Debug mode? - if debug: - common.set_debug(debug) - web.debug_mode() + # Make sure filenames given if not using receiver mode + if not receive and len(filenames) == 0: + print(strings._('no_filenames')) + sys.exit() - # Validation - valid = True - for filename in filenames: - if not os.path.isfile(filename) and not os.path.isdir(filename): - print(strings._("not_a_file").format(filename)) - valid = False - if not os.access(filename, os.R_OK): - print(strings._("not_a_readable_file").format(filename)) + # Validate filenames + if not receive: + valid = True + for filename in filenames: + if not os.path.isfile(filename) and not os.path.isdir(filename): + print(strings._("not_a_file").format(filename)) + valid = False + if not os.access(filename, os.R_OK): + print(strings._("not_a_readable_file").format(filename)) + valid = False + if not valid: + sys.exit() + + # Load settings + common.load_settings(config) + + # Debug mode? + common.debug = debug + + # In receive mode, validate downloads dir + if receive: + valid = True + if not os.path.isdir(common.settings.get('downloads_dir')): + try: + os.mkdir(common.settings.get('downloads_dir'), 0o700) + except: + print(strings._('error_cannot_create_downloads_dir').format(common.settings.get('downloads_dir'))) + valid = False + if valid and not os.access(common.settings.get('downloads_dir'), os.W_OK): + print(strings._('error_downloads_dir_not_writable').format(common.settings.get('downloads_dir'))) valid = False if not valid: sys.exit() - - settings = Settings(config) + # Create the Web object + web = Web(common, stay_open, False, receive) # Start the Onion object - onion = Onion() + onion = Onion(common) try: - onion.connect(settings=False, config=config) + onion.connect(custom_settings=False, config=config) except (TorTooOld, TorErrorInvalidSetting, TorErrorAutomatic, TorErrorSocketPort, TorErrorSocketFile, TorErrorMissingPassword, TorErrorUnreadableCookieFile, TorErrorAuthError, TorErrorProtocolError, BundledTorNotSupported, BundledTorTimeout) as e: sys.exit(e.args[0]) except KeyboardInterrupt: @@ -92,7 +119,7 @@ def main(cwd=None): # Start the onionshare app try: - app = OnionShare(onion, local_only, stay_open, shutdown_timeout) + app = OnionShare(common, onion, local_only, stay_open, shutdown_timeout) app.set_stealth(stealth) app.start_onion_service() except KeyboardInterrupt: @@ -115,8 +142,7 @@ def main(cwd=None): print('') # Start OnionShare http service in new thread - settings.load() - t = threading.Thread(target=web.start, args=(app.port, app.stay_open, settings.get('slug'))) + t = threading.Thread(target=web.start, args=(app.port, app.stay_open, common.settings.get('slug'))) t.daemon = True t.start() @@ -129,18 +155,33 @@ def main(cwd=None): app.shutdown_timer.start() # Save the web slug if we are using a persistent private key - if settings.get('save_private_key'): - if not settings.get('slug'): - settings.set('slug', web.slug) - settings.save() + if common.settings.get('save_private_key'): + if not common.settings.get('slug'): + common.settings.set('slug', web.slug) + common.settings.save() - if(stealth): - print(strings._("give_this_url_stealth")) - print('http://{0:s}/{1:s}'.format(app.onion_host, web.slug)) - print(app.auth_string) + print('') + if receive: + print(strings._('receive_mode_downloads_dir').format(common.settings.get('downloads_dir'))) + print('') + print(strings._('receive_mode_warning')) + print('') + + if stealth: + print(strings._("give_this_url_receive_stealth")) + print('http://{0:s}/{1:s}'.format(app.onion_host, web.slug)) + print(app.auth_string) + else: + print(strings._("give_this_url_receive")) + print('http://{0:s}/{1:s}'.format(app.onion_host, web.slug)) else: - print(strings._("give_this_url")) - print('http://{0:s}/{1:s}'.format(app.onion_host, web.slug)) + if stealth: + print(strings._("give_this_url_stealth")) + print('http://{0:s}/{1:s}'.format(app.onion_host, web.slug)) + print(app.auth_string) + else: + print(strings._("give_this_url")) + print('http://{0:s}/{1:s}'.format(app.onion_host, web.slug)) print('') print(strings._("ctrlc_to_stop")) diff --git a/onionshare/common.py b/onionshare/common.py index 0d00c7b1..903e4148 100644 --- a/onionshare/common.py +++ b/onionshare/common.py @@ -28,262 +28,207 @@ import sys import tempfile import threading import time -import zipfile -debug = False +from .settings import Settings - -def log(module, func, msg=None): +class Common(object): """ - If debug mode is on, log error messages to stdout + The Common object is shared amongst all parts of OnionShare. """ - global debug - if debug: - timestamp = time.strftime("%b %d %Y %X") + def __init__(self, debug=False): + self.debug = debug - final_msg = "[{}] {}.{}".format(timestamp, module, func) - if msg: - final_msg = '{}: {}'.format(final_msg, msg) - print(final_msg) + # The platform OnionShare is running on + self.platform = platform.system() + if self.platform.endswith('BSD'): + self.platform = 'BSD' + # The current version of OnionShare + with open(self.get_resource_path('version.txt')) as f: + self.version = f.read().strip() -def set_debug(new_debug): - global debug - debug = new_debug + def load_settings(self, config=None): + """ + Loading settings, optionally from a custom config json file. + """ + self.settings = Settings(self, config) + self.settings.load() + def log(self, module, func, msg=None): + """ + If debug mode is on, log error messages to stdout + """ + if self.debug: + timestamp = time.strftime("%b %d %Y %X") -def get_platform(): - """ - Returns the platform OnionShare is running on. - """ - plat = platform.system() - if plat.endswith('BSD'): - plat = 'BSD' - return plat + final_msg = "[{}] {}.{}".format(timestamp, module, func) + if msg: + final_msg = '{}: {}'.format(final_msg, msg) + print(final_msg) + def get_resource_path(self, filename): + """ + Returns the absolute path of a resource, regardless of whether OnionShare is installed + systemwide, and whether regardless of platform + """ + # On Windows, and in Windows dev mode, switch slashes in incoming filename to backslackes + if self.platform == 'Windows': + filename = filename.replace('/', '\\') -def get_resource_path(filename): - """ - Returns the absolute path of a resource, regardless of whether OnionShare is installed - systemwide, and whether regardless of platform - """ - p = get_platform() + if getattr(sys, 'onionshare_dev_mode', False): + # Look for resources directory relative to python file + prefix = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))), 'share') + if not os.path.exists(prefix): + # While running tests during stdeb bdist_deb, look 3 directories up for the share folder + prefix = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(prefix)))), 'share') - # On Windows, and in Windows dev mode, switch slashes in incoming filename to backslackes - if p == 'Windows': - filename = filename.replace('/', '\\') + elif self.platform == 'BSD' or self.platform == 'Linux': + # Assume OnionShare is installed systemwide in Linux, since we're not running in dev mode + prefix = os.path.join(sys.prefix, 'share/onionshare') - if getattr(sys, 'onionshare_dev_mode', False): - # Look for resources directory relative to python file - prefix = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))), 'share') - if not os.path.exists(prefix): - # While running tests during stdeb bdist_deb, look 3 directories up for the share folder - prefix = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(prefix)))), 'share') + elif getattr(sys, 'frozen', False): + # Check if app is "frozen" + # https://pythonhosted.org/PyInstaller/#run-time-information + if self.platform == 'Darwin': + prefix = os.path.join(sys._MEIPASS, 'share') + elif self.platform == 'Windows': + prefix = os.path.join(os.path.dirname(sys.executable), 'share') - elif p == 'BSD' or p == 'Linux': - # Assume OnionShare is installed systemwide in Linux, since we're not running in dev mode - prefix = os.path.join(sys.prefix, 'share/onionshare') + return os.path.join(prefix, filename) - elif getattr(sys, 'frozen', False): - # Check if app is "frozen" - # https://pythonhosted.org/PyInstaller/#run-time-information - if p == 'Darwin': - prefix = os.path.join(sys._MEIPASS, 'share') - elif p == 'Windows': - prefix = os.path.join(os.path.dirname(sys.executable), 'share') + def get_tor_paths(self): + if self.platform == 'Linux': + tor_path = '/usr/bin/tor' + tor_geo_ip_file_path = '/usr/share/tor/geoip' + tor_geo_ipv6_file_path = '/usr/share/tor/geoip6' + obfs4proxy_file_path = '/usr/bin/obfs4proxy' + elif self.platform == 'Windows': + base_path = os.path.join(os.path.dirname(os.path.dirname(self.get_resource_path(''))), 'tor') + tor_path = os.path.join(os.path.join(base_path, 'Tor'), 'tor.exe') + obfs4proxy_file_path = os.path.join(os.path.join(base_path, 'Tor'), 'obfs4proxy.exe') + tor_geo_ip_file_path = os.path.join(os.path.join(os.path.join(base_path, 'Data'), 'Tor'), 'geoip') + tor_geo_ipv6_file_path = os.path.join(os.path.join(os.path.join(base_path, 'Data'), 'Tor'), 'geoip6') + elif self.platform == 'Darwin': + base_path = os.path.dirname(os.path.dirname(os.path.dirname(self.get_resource_path('')))) + tor_path = os.path.join(base_path, 'Resources', 'Tor', 'tor') + tor_geo_ip_file_path = os.path.join(base_path, 'Resources', 'Tor', 'geoip') + tor_geo_ipv6_file_path = os.path.join(base_path, 'Resources', 'Tor', 'geoip6') + obfs4proxy_file_path = os.path.join(base_path, 'Resources', 'Tor', 'obfs4proxy') + elif self.platform == 'BSD': + tor_path = '/usr/local/bin/tor' + tor_geo_ip_file_path = '/usr/local/share/tor/geoip' + tor_geo_ipv6_file_path = '/usr/local/share/tor/geoip6' + obfs4proxy_file_path = '/usr/local/bin/obfs4proxy' - return os.path.join(prefix, filename) + return (tor_path, tor_geo_ip_file_path, tor_geo_ipv6_file_path, obfs4proxy_file_path) + def build_slug(self): + """ + Returns a random string made from two words from the wordlist, such as "deter-trig". + """ + with open(self.get_resource_path('wordlist.txt')) as f: + wordlist = f.read().split() -def get_tor_paths(): - p = get_platform() - if p == 'Linux': - tor_path = '/usr/bin/tor' - tor_geo_ip_file_path = '/usr/share/tor/geoip' - tor_geo_ipv6_file_path = '/usr/share/tor/geoip6' - obfs4proxy_file_path = '/usr/bin/obfs4proxy' - elif p == 'Windows': - base_path = os.path.join(os.path.dirname(os.path.dirname(get_resource_path(''))), 'tor') - tor_path = os.path.join(os.path.join(base_path, 'Tor'), 'tor.exe') - obfs4proxy_file_path = os.path.join(os.path.join(base_path, 'Tor'), 'obfs4proxy.exe') - tor_geo_ip_file_path = os.path.join(os.path.join(os.path.join(base_path, 'Data'), 'Tor'), 'geoip') - tor_geo_ipv6_file_path = os.path.join(os.path.join(os.path.join(base_path, 'Data'), 'Tor'), 'geoip6') - elif p == 'Darwin': - base_path = os.path.dirname(os.path.dirname(os.path.dirname(get_resource_path('')))) - tor_path = os.path.join(base_path, 'Resources', 'Tor', 'tor') - tor_geo_ip_file_path = os.path.join(base_path, 'Resources', 'Tor', 'geoip') - tor_geo_ipv6_file_path = os.path.join(base_path, 'Resources', 'Tor', 'geoip6') - obfs4proxy_file_path = os.path.join(base_path, 'Resources', 'Tor', 'obfs4proxy') - elif p == 'BSD': - tor_path = '/usr/local/bin/tor' - tor_geo_ip_file_path = '/usr/local/share/tor/geoip' - tor_geo_ipv6_file_path = '/usr/local/share/tor/geoip6' - obfs4proxy_file_path = '/usr/local/bin/obfs4proxy' + r = random.SystemRandom() + return '-'.join(r.choice(wordlist) for _ in range(2)) - return (tor_path, tor_geo_ip_file_path, tor_geo_ipv6_file_path, obfs4proxy_file_path) + @staticmethod + def random_string(num_bytes, output_len=None): + """ + Returns a random string with a specified number of bytes. + """ + b = os.urandom(num_bytes) + h = hashlib.sha256(b).digest()[:16] + s = base64.b32encode(h).lower().replace(b'=', b'').decode('utf-8') + if not output_len: + return s + return s[:output_len] - -def get_version(): - """ - Returns the version of OnionShare that is running. - """ - with open(get_resource_path('version.txt')) as f: - version = f.read().strip() - return version - - -def random_string(num_bytes, output_len=None): - """ - Returns a random string with a specified number of bytes. - """ - b = os.urandom(num_bytes) - h = hashlib.sha256(b).digest()[:16] - s = base64.b32encode(h).lower().replace(b'=', b'').decode('utf-8') - if not output_len: - return s - return s[:output_len] - - -def build_slug(): - """ - Returns a random string made from two words from the wordlist, such as "deter-trig". - """ - with open(get_resource_path('wordlist.txt')) as f: - wordlist = f.read().split() - - r = random.SystemRandom() - return '-'.join(r.choice(wordlist) for _ in range(2)) - - -def human_readable_filesize(b): - """ - Returns filesize in a human readable format. - """ - thresh = 1024.0 - if b < thresh: - return '{:.1f} B'.format(b) - units = ('KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB') - u = 0 - b /= thresh - while b >= thresh: + @staticmethod + def human_readable_filesize(b): + """ + Returns filesize in a human readable format. + """ + thresh = 1024.0 + if b < thresh: + return '{:.1f} B'.format(b) + units = ('KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB') + u = 0 b /= thresh - u += 1 - return '{:.1f} {}'.format(b, units[u]) + while b >= thresh: + b /= thresh + u += 1 + return '{:.1f} {}'.format(b, units[u]) + @staticmethod + def format_seconds(seconds): + """Return a human-readable string of the format 1d2h3m4s""" + days, seconds = divmod(seconds, 86400) + hours, seconds = divmod(seconds, 3600) + minutes, seconds = divmod(seconds, 60) -def format_seconds(seconds): - """Return a human-readable string of the format 1d2h3m4s""" - days, seconds = divmod(seconds, 86400) - hours, seconds = divmod(seconds, 3600) - minutes, seconds = divmod(seconds, 60) + human_readable = [] + if days: + human_readable.append("{:.0f}d".format(days)) + if hours: + human_readable.append("{:.0f}h".format(hours)) + if minutes: + human_readable.append("{:.0f}m".format(minutes)) + if seconds or not human_readable: + human_readable.append("{:.0f}s".format(seconds)) + return ''.join(human_readable) - human_readable = [] - if days: - human_readable.append("{:.0f}d".format(days)) - if hours: - human_readable.append("{:.0f}h".format(hours)) - if minutes: - human_readable.append("{:.0f}m".format(minutes)) - if seconds or not human_readable: - human_readable.append("{:.0f}s".format(seconds)) - return ''.join(human_readable) + @staticmethod + def estimated_time_remaining(bytes_downloaded, total_bytes, started): + now = time.time() + time_elapsed = now - started # in seconds + download_rate = bytes_downloaded / time_elapsed + remaining_bytes = total_bytes - bytes_downloaded + eta = remaining_bytes / download_rate + return Common.format_seconds(eta) - -def estimated_time_remaining(bytes_downloaded, total_bytes, started): - now = time.time() - time_elapsed = now - started # in seconds - download_rate = bytes_downloaded / time_elapsed - remaining_bytes = total_bytes - bytes_downloaded - eta = remaining_bytes / download_rate - return format_seconds(eta) - - -def get_available_port(min_port, max_port): - """ - Find a random available port within the given range. - """ - with socket.socket() as tmpsock: - while True: - try: - tmpsock.bind(("127.0.0.1", random.randint(min_port, max_port))) - break - except OSError as e: - raise OSError(e) - _, port = tmpsock.getsockname() - return port - - -def dir_size(start_path): - """ - Calculates the total size, in bytes, of all of the files in a directory. - """ - total_size = 0 - for dirpath, dirnames, filenames in os.walk(start_path): - for f in filenames: - fp = os.path.join(dirpath, f) - if not os.path.islink(fp): - total_size += os.path.getsize(fp) - return total_size - - -class ZipWriter(object): - """ - ZipWriter accepts files and directories and compresses them into a zip file - with. If a zip_filename is not passed in, it will use the default onionshare - filename. - """ - def __init__(self, zip_filename=None, processed_size_callback=None): - if zip_filename: - self.zip_filename = zip_filename - else: - self.zip_filename = '{0:s}/onionshare_{1:s}.zip'.format(tempfile.mkdtemp(), random_string(4, 6)) - - self.z = zipfile.ZipFile(self.zip_filename, 'w', allowZip64=True) - self.processed_size_callback = processed_size_callback - if self.processed_size_callback is None: - self.processed_size_callback = lambda _: None - self._size = 0 - self.processed_size_callback(self._size) - - def add_file(self, filename): + @staticmethod + def get_available_port(min_port, max_port): """ - Add a file to the zip archive. + Find a random available port within the given range. """ - self.z.write(filename, os.path.basename(filename), zipfile.ZIP_DEFLATED) - self._size += os.path.getsize(filename) - self.processed_size_callback(self._size) + with socket.socket() as tmpsock: + while True: + try: + tmpsock.bind(("127.0.0.1", random.randint(min_port, max_port))) + break + except OSError as e: + raise OSError(e) + _, port = tmpsock.getsockname() + return port - def add_dir(self, filename): + @staticmethod + def dir_size(start_path): """ - Add a directory, and all of its children, to the zip archive. + Calculates the total size, in bytes, of all of the files in a directory. """ - dir_to_strip = os.path.dirname(filename.rstrip('/'))+'/' - for dirpath, dirnames, filenames in os.walk(filename): + total_size = 0 + for dirpath, dirnames, filenames in os.walk(start_path): for f in filenames: - full_filename = os.path.join(dirpath, f) - if not os.path.islink(full_filename): - arc_filename = full_filename[len(dir_to_strip):] - self.z.write(full_filename, arc_filename, zipfile.ZIP_DEFLATED) - self._size += os.path.getsize(full_filename) - self.processed_size_callback(self._size) - - def close(self): - """ - Close the zip archive. - """ - self.z.close() + fp = os.path.join(dirpath, f) + if not os.path.islink(fp): + total_size += os.path.getsize(fp) + return total_size -class close_after_seconds(threading.Thread): +class ShutdownTimer(threading.Thread): """ Background thread sleeps t hours and returns. """ - def __init__(self, time): + def __init__(self, common, time): threading.Thread.__init__(self) + + self.common = common + self.setDaemon(True) self.time = time def run(self): - log('Shutdown Timer', 'Server will shut down after {} seconds'.format(self.time)) + self.common.log('Shutdown Timer', 'Server will shut down after {} seconds'.format(self.time)) time.sleep(self.time) return 1 diff --git a/onionshare/onion.py b/onionshare/onion.py index 068648ba..dc47019f 100644 --- a/onionshare/onion.py +++ b/onionshare/onion.py @@ -125,22 +125,22 @@ class Onion(object): call this function and pass in a status string while connecting to tor. This is necessary for status updates to reach the GUI. """ - def __init__(self): - common.log('Onion', '__init__') + def __init__(self, common): + self.common = common + + self.common.log('Onion', '__init__') self.stealth = False self.service_id = None - self.system = common.get_platform() - # Is bundled tor supported? - if (self.system == 'Windows' or self.system == 'Darwin') and getattr(sys, 'onionshare_dev_mode', False): + if (self.common.platform == 'Windows' or self.common.platform == 'Darwin') and getattr(sys, 'onionshare_dev_mode', False): self.bundle_tor_supported = False else: self.bundle_tor_supported = True # Set the path of the tor binary, for bundled tor - (self.tor_path, self.tor_geo_ip_file_path, self.tor_geo_ipv6_file_path, self.obfs4proxy_file_path) = common.get_tor_paths() + (self.tor_path, self.tor_geo_ip_file_path, self.tor_geo_ipv6_file_path, self.obfs4proxy_file_path) = self.common.get_tor_paths() # The tor process self.tor_proc = None @@ -148,15 +148,14 @@ class Onion(object): # Start out not connected to Tor self.connected_to_tor = False - def connect(self, settings=False, config=False, tor_status_update_func=None): - common.log('Onion', 'connect') + def connect(self, custom_settings=False, config=False, tor_status_update_func=None): + self.common.log('Onion', 'connect') - # Either use settings that are passed in, or load them from disk - if settings: - self.settings = settings + # Either use settings that are passed in, or use them from common + if custom_settings: + self.settings = custom_settings else: - self.settings = Settings(config) - self.settings.load() + self.settings = self.common.settings # The Tor controller self.c = None @@ -168,29 +167,29 @@ class Onion(object): # Create a torrc for this session self.tor_data_directory = tempfile.TemporaryDirectory() - if self.system == 'Windows': + if self.common.platform == 'Windows': # Windows needs to use network ports, doesn't support unix sockets - torrc_template = open(common.get_resource_path('torrc_template-windows')).read() + torrc_template = open(self.common.get_resource_path('torrc_template-windows')).read() try: - self.tor_control_port = common.get_available_port(1000, 65535) + self.tor_control_port = self.common.get_available_port(1000, 65535) except: raise OSError(strings._('no_available_port')) self.tor_control_socket = None self.tor_cookie_auth_file = os.path.join(self.tor_data_directory.name, 'cookie') try: - self.tor_socks_port = common.get_available_port(1000, 65535) + self.tor_socks_port = self.common.get_available_port(1000, 65535) except: raise OSError(strings._('no_available_port')) self.tor_torrc = os.path.join(self.tor_data_directory.name, 'torrc') else: # Linux, Mac and BSD can use unix sockets - with open(common.get_resource_path('torrc_template')) as f: + with open(self.common.get_resource_path('torrc_template')) as f: torrc_template = f.read() self.tor_control_port = None self.tor_control_socket = os.path.join(self.tor_data_directory.name, 'control_socket') self.tor_cookie_auth_file = os.path.join(self.tor_data_directory.name, 'cookie') try: - self.tor_socks_port = common.get_available_port(1000, 65535) + self.tor_socks_port = self.common.get_available_port(1000, 65535) except: raise OSError(strings._('no_available_port')) self.tor_torrc = os.path.join(self.tor_data_directory.name, 'torrc') @@ -208,17 +207,17 @@ class Onion(object): # Bridge support if self.settings.get('tor_bridges_use_obfs4'): f.write('ClientTransportPlugin obfs4 exec {}\n'.format(self.obfs4proxy_file_path)) - with open(common.get_resource_path('torrc_template-obfs4')) as o: + with open(self.common.get_resource_path('torrc_template-obfs4')) as o: for line in o: f.write(line) elif self.settings.get('tor_bridges_use_meek_lite_amazon'): f.write('ClientTransportPlugin meek_lite exec {}\n'.format(self.obfs4proxy_file_path)) - with open(common.get_resource_path('torrc_template-meek_lite_amazon')) as o: + with open(self.common.get_resource_path('torrc_template-meek_lite_amazon')) as o: for line in o: f.write(line) elif self.settings.get('tor_bridges_use_meek_lite_azure'): f.write('ClientTransportPlugin meek_lite exec {}\n'.format(self.obfs4proxy_file_path)) - with open(common.get_resource_path('torrc_template-meek_lite_azure')) as o: + with open(self.common.get_resource_path('torrc_template-meek_lite_azure')) as o: for line in o: f.write(line) @@ -232,7 +231,7 @@ class Onion(object): # Execute a tor subprocess start_ts = time.time() - if self.system == 'Windows': + if self.common.platform == 'Windows': # In Windows, hide console window when opening tor.exe subprocess startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW @@ -245,7 +244,7 @@ class Onion(object): # Connect to the controller try: - if self.system == 'Windows': + if self.common.platform == 'Windows': self.c = Controller.from_port(port=self.tor_control_port) self.c.authenticate() else: @@ -270,7 +269,7 @@ class Onion(object): if callable(tor_status_update_func): if not tor_status_update_func(progress, summary): # If the dialog was canceled, stop connecting to Tor - common.log('Onion', 'connect', 'tor_status_update_func returned false, canceling connecting to Tor') + self.common.log('Onion', 'connect', 'tor_status_update_func returned false, canceling connecting to Tor') print() return False @@ -322,7 +321,7 @@ class Onion(object): socket_file_path = '' if not found_tor: try: - if self.system == 'Darwin': + if self.common.platform == 'Darwin': socket_file_path = os.path.expanduser('~/Library/Application Support/TorBrowser-Data/Tor/control.socket') self.c = Controller.from_socket_file(path=socket_file_path) @@ -334,11 +333,11 @@ class Onion(object): # guessing the socket file name next if not found_tor: try: - if self.system == 'Linux' or self.system == 'BSD': + if self.common.platform == 'Linux' or self.common.platform == 'BSD': socket_file_path = '/run/user/{}/Tor/control.socket'.format(os.geteuid()) - elif self.system == 'Darwin': + elif self.common.platform == 'Darwin': socket_file_path = '/run/user/{}/Tor/control.socket'.format(os.geteuid()) - elif self.system == 'Windows': + elif self.common.platform == 'Windows': # Windows doesn't support unix sockets raise TorErrorAutomatic(strings._('settings_error_automatic')) @@ -424,7 +423,7 @@ class Onion(object): Start a onion service on port 80, pointing to the given port, and return the onion hostname. """ - common.log('Onion', 'start_onion_service') + self.common.log('Onion', 'start_onion_service') self.auth_string = None if not self.supports_ephemeral: @@ -447,11 +446,11 @@ class Onion(object): if self.settings.get('private_key'): key_type = "RSA1024" key_content = self.settings.get('private_key') - common.log('Onion', 'Starting a hidden service with a saved private key') + self.common.log('Onion', 'start_onion_service', 'Starting a hidden service with a saved private key') else: key_type = "NEW" key_content = "RSA1024" - common.log('Onion', 'Starting a hidden service with a new private key') + self.common.log('Onion', 'start_onion_service', 'Starting a hidden service with a new private key') try: if basic_auth != None: @@ -498,17 +497,17 @@ class Onion(object): """ Stop onion services that were created earlier. If there's a tor subprocess running, kill it. """ - common.log('Onion', 'cleanup') + self.common.log('Onion', 'cleanup') # Cleanup the ephemeral onion services, if we have any try: onions = self.c.list_ephemeral_hidden_services() for onion in onions: try: - common.log('Onion', 'cleanup', 'trying to remove onion {}'.format(onion)) + self.common.log('Onion', 'cleanup', 'trying to remove onion {}'.format(onion)) self.c.remove_ephemeral_hidden_service(onion) except: - common.log('Onion', 'cleanup', 'could not remove onion {}.. moving on anyway'.format(onion)) + self.common.log('Onion', 'cleanup', 'could not remove onion {}.. moving on anyway'.format(onion)) pass except: pass @@ -545,7 +544,7 @@ class Onion(object): """ Returns a (address, port) tuple for the Tor SOCKS port """ - common.log('Onion', 'get_tor_socks_port') + self.common.log('Onion', 'get_tor_socks_port') if self.settings.get('connection_type') == 'bundled': return ('127.0.0.1', self.tor_socks_port) diff --git a/onionshare/onionshare.py b/onionshare/onionshare.py index 85bfaf22..10d73751 100644 --- a/onionshare/onionshare.py +++ b/onionshare/onionshare.py @@ -21,14 +21,17 @@ along with this program. If not, see . import os, shutil from . import common, strings +from .common import ShutdownTimer class OnionShare(object): """ OnionShare is the main application class. Pass in options and run start_onion_service and it will do the magic. """ - def __init__(self, onion, local_only=False, stay_open=False, shutdown_timeout=0): - common.log('OnionShare', '__init__') + def __init__(self, common, onion, local_only=False, stay_open=False, shutdown_timeout=0): + self.common = common + + self.common.log('OnionShare', '__init__') # The Onion object self.onion = onion @@ -52,7 +55,7 @@ class OnionShare(object): self.shutdown_timer = None def set_stealth(self, stealth): - common.log('OnionShare', 'set_stealth', 'stealth={}'.format(stealth)) + self.common.log('OnionShare', 'set_stealth', 'stealth={}'.format(stealth)) self.stealth = stealth self.onion.stealth = stealth @@ -61,11 +64,11 @@ class OnionShare(object): """ Start the onionshare onion service. """ - common.log('OnionShare', 'start_onion_service') + self.common.log('OnionShare', 'start_onion_service') # Choose a random port try: - self.port = common.get_available_port(17600, 17650) + self.port = self.common.get_available_port(17600, 17650) except: raise OSError(strings._('no_available_port')) @@ -74,7 +77,7 @@ class OnionShare(object): return if self.shutdown_timeout > 0: - self.shutdown_timer = common.close_after_seconds(self.shutdown_timeout) + self.shutdown_timer = ShutdownTimer(self.common, self.shutdown_timeout) self.onion_host = self.onion.start_onion_service(self.port) @@ -85,7 +88,7 @@ class OnionShare(object): """ Shut everything down and clean up temporary files, etc. """ - common.log('OnionShare', 'cleanup') + self.common.log('OnionShare', 'cleanup') # cleanup files for filename in self.cleanup_filenames: diff --git a/onionshare/settings.py b/onionshare/settings.py index 545915e8..1c7638c0 100644 --- a/onionshare/settings.py +++ b/onionshare/settings.py @@ -22,7 +22,7 @@ import json import os import platform -from . import strings, common +from . import strings class Settings(object): @@ -32,8 +32,10 @@ class Settings(object): which is to attempt to connect automatically using default Tor Browser settings. """ - def __init__(self, config=False): - common.log('Settings', '__init__') + def __init__(self, common, config=False): + self.common = common + + self.common.log('Settings', '__init__') # Default config self.filename = self.build_filename() @@ -43,11 +45,11 @@ class Settings(object): if os.path.isfile(config): self.filename = config else: - common.log('Settings', '__init__', 'Supplied config does not exist or is unreadable. Falling back to default location') + self.common.log('Settings', '__init__', 'Supplied config does not exist or is unreadable. Falling back to default location') # These are the default settings. They will get overwritten when loading from disk self.default_settings = { - 'version': common.get_version(), + 'version': self.common.version, 'connection_type': 'bundled', 'control_port_address': '127.0.0.1', 'control_port_port': 9051, @@ -70,7 +72,8 @@ class Settings(object): 'save_private_key': False, 'private_key': '', 'slug': '', - 'hidservauth_string': '' + 'hidservauth_string': '', + 'downloads_dir': self.build_default_downloads_dir() } self._settings = {} self.fill_in_defaults() @@ -97,16 +100,24 @@ class Settings(object): else: return os.path.expanduser('~/.config/onionshare/onionshare.json') + def build_default_downloads_dir(self): + """ + Returns the path of the default Downloads directory for receive mode. + """ + # TODO: Test in Windows, though it looks like it should work + # https://docs.python.org/3/library/os.path.html#os.path.expanduser + return os.path.expanduser('~/OnionShare') + def load(self): """ Load the settings from file. """ - common.log('Settings', 'load') + self.common.log('Settings', 'load') # If the settings file exists, load it if os.path.exists(self.filename): try: - common.log('Settings', 'load', 'Trying to load {}'.format(self.filename)) + self.common.log('Settings', 'load', 'Trying to load {}'.format(self.filename)) with open(self.filename, 'r') as f: self._settings = json.load(f) self.fill_in_defaults() @@ -117,7 +128,7 @@ class Settings(object): """ Save settings to file. """ - common.log('Settings', 'save') + self.common.log('Settings', 'save') try: os.makedirs(os.path.dirname(self.filename)) diff --git a/onionshare/web.py b/onionshare/web.py index d16ca251..7a6a848b 100644 --- a/onionshare/web.py +++ b/onionshare/web.py @@ -26,422 +26,593 @@ import queue import socket import sys import tempfile -import base64 +import zipfile +import re +import io from distutils.version import LooseVersion as Version from urllib.request import urlopen from flask import ( - Flask, Response, request, render_template_string, abort, make_response, - __version__ as flask_version + Flask, Response, Request, request, render_template, abort, make_response, + flash, redirect, __version__ as flask_version ) +from werkzeug.utils import secure_filename from . import strings, common - -def _safe_select_jinja_autoescape(self, filename): - if filename is None: - return True - return filename.endswith(('.html', '.htm', '.xml', '.xhtml')) - -# Starting in Flask 0.11, render_template_string autoescapes template variables -# by default. To prevent content injection through template variables in -# earlier versions of Flask, we force autoescaping in the Jinja2 template -# engine if we detect a Flask version with insecure default behavior. -if Version(flask_version) < Version('0.11'): - # Monkey-patch in the fix from https://github.com/pallets/flask/commit/99c99c4c16b1327288fd76c44bc8635a1de452bc - Flask.select_jinja_autoescape = _safe_select_jinja_autoescape - -app = Flask(__name__) - -# information about the file -file_info = [] -zip_filename = None -zip_filesize = None - -security_headers = [ - ('Content-Security-Policy', 'default-src \'self\'; style-src \'unsafe-inline\'; script-src \'unsafe-inline\'; img-src \'self\' data:;'), - ('X-Frame-Options', 'DENY'), - ('X-Xss-Protection', '1; mode=block'), - ('X-Content-Type-Options', 'nosniff'), - ('Referrer-Policy', 'no-referrer'), - ('Server', 'OnionShare') -] - - -def set_file_info(filenames, processed_size_callback=None): +class Web(object): """ - Using the list of filenames being shared, fill in details that the web - page will need to display. This includes zipping up the file in order to - get the zip file's name and size. + The Web object is the OnionShare web server, powered by flask """ - global file_info, zip_filename, zip_filesize + def __init__(self, common, stay_open, gui_mode, receive_mode=False): + self.common = common - # build file info list - file_info = {'files': [], 'dirs': []} - for filename in filenames: - info = { - 'filename': filename, - 'basename': os.path.basename(filename.rstrip('/')) - } - if os.path.isfile(filename): - info['size'] = os.path.getsize(filename) - info['size_human'] = common.human_readable_filesize(info['size']) - file_info['files'].append(info) - if os.path.isdir(filename): - info['size'] = common.dir_size(filename) - info['size_human'] = common.human_readable_filesize(info['size']) - file_info['dirs'].append(info) - file_info['files'] = sorted(file_info['files'], key=lambda k: k['basename']) - file_info['dirs'] = sorted(file_info['dirs'], key=lambda k: k['basename']) + # The flask app + self.app = Flask(__name__, + static_folder=common.get_resource_path('static'), + template_folder=common.get_resource_path('templates')) + self.app.secret_key = self.common.random_string(8) - # zip up the files and folders - z = common.ZipWriter(processed_size_callback=processed_size_callback) - for info in file_info['files']: - z.add_file(info['filename']) - for info in file_info['dirs']: - z.add_dir(info['filename']) - z.close() - zip_filename = z.zip_filename - zip_filesize = os.path.getsize(zip_filename) + # Debug mode? + if self.common.debug: + self.debug_mode() + # Stay open after the first download? + self.stay_open = stay_open -REQUEST_LOAD = 0 -REQUEST_DOWNLOAD = 1 -REQUEST_PROGRESS = 2 -REQUEST_OTHER = 3 -REQUEST_CANCELED = 4 -REQUEST_RATE_LIMIT = 5 -q = queue.Queue() + # Are we running in GUI mode? + self.gui_mode = gui_mode + # Are we using receive mode? + self.receive_mode = receive_mode + if self.receive_mode: + # Use custom WSGI middleware, to modify environ + self.app.wsgi_app = ReceiveModeWSGIMiddleware(self.app.wsgi_app, self) + # Use a custom Request class to track upload progess + self.app.request_class = ReceiveModeRequest -def add_request(request_type, path, data=None): - """ - Add a request to the queue, to communicate with the GUI. - """ - global q - q.put({ - 'type': request_type, - 'path': path, - 'data': data - }) + # Starting in Flask 0.11, render_template_string autoescapes template variables + # by default. To prevent content injection through template variables in + # earlier versions of Flask, we force autoescaping in the Jinja2 template + # engine if we detect a Flask version with insecure default behavior. + if Version(flask_version) < Version('0.11'): + # Monkey-patch in the fix from https://github.com/pallets/flask/commit/99c99c4c16b1327288fd76c44bc8635a1de452bc + Flask.select_jinja_autoescape = self._safe_select_jinja_autoescape + # Information about the file + self.file_info = [] + self.zip_filename = None + self.zip_filesize = None -# Load and base64 encode images to pass into templates -favicon_b64 = base64.b64encode(open(common.get_resource_path('images/favicon.ico'), 'rb').read()).decode() -logo_b64 = base64.b64encode(open(common.get_resource_path('images/logo.png'), 'rb').read()).decode() -folder_b64 = base64.b64encode(open(common.get_resource_path('images/web_folder.png'), 'rb').read()).decode() -file_b64 = base64.b64encode(open(common.get_resource_path('images/web_file.png'), 'rb').read()).decode() + self.security_headers = [ + ('Content-Security-Policy', 'default-src \'self\'; style-src \'self\'; script-src \'self\'; img-src \'self\' data:;'), + ('X-Frame-Options', 'DENY'), + ('X-Xss-Protection', '1; mode=block'), + ('X-Content-Type-Options', 'nosniff'), + ('Referrer-Policy', 'no-referrer'), + ('Server', 'OnionShare') + ] -slug = None + self.REQUEST_LOAD = 0 + self.REQUEST_DOWNLOAD = 1 + self.REQUEST_PROGRESS = 2 + self.REQUEST_OTHER = 3 + self.REQUEST_CANCELED = 4 + self.REQUEST_RATE_LIMIT = 5 + self.q = queue.Queue() + self.slug = None -def generate_slug(persistent_slug=''): - global slug - if persistent_slug: - slug = persistent_slug - else: - slug = common.build_slug() + self.download_count = 0 + self.error404_count = 0 -download_count = 0 -error404_count = 0 + # If "Stop After First Download" is checked (stay_open == False), only allow + # one download at a time. + self.download_in_progress = False -stay_open = False + self.done = False + # If the client closes the OnionShare window while a download is in progress, + # it should immediately stop serving the file. The client_cancel global is + # used to tell the download function that the client is canceling the download. + self.client_cancel = False -def set_stay_open(new_stay_open): - """ - Set stay_open variable. - """ - global stay_open - stay_open = new_stay_open + # shutting down the server only works within the context of flask, so the easiest way to do it is over http + self.shutdown_slug = self.common.random_string(16) + # Define the ewb app routes + self.common_routes() + if self.receive_mode: + self.receive_routes() + else: + self.send_routes() -def get_stay_open(): - """ - Get stay_open variable. - """ - return stay_open + def send_routes(self): + """ + The web app routes for sharing files + """ + @self.app.route("/") + def index(slug_candidate): + """ + Render the template for the onionshare landing page. + """ + self.check_slug_candidate(slug_candidate) + self.add_request(self.REQUEST_LOAD, request.path) -# Are we running in GUI mode? -gui_mode = False + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not self.stay_open and self.download_in_progress + if deny_download: + r = make_response(render_template('denied.html')) + return self.add_security_headers(r) + # If download is allowed to continue, serve download page + r = make_response(render_template( + 'send.html', + slug=self.slug, + file_info=self.file_info, + filename=os.path.basename(self.zip_filename), + filesize=self.zip_filesize, + filesize_human=self.common.human_readable_filesize(self.zip_filesize))) + return self.add_security_headers(r) -def set_gui_mode(): - """ - Tell the web service that we're running in GUI mode - """ - global gui_mode - gui_mode = True + @self.app.route("//download") + def download(slug_candidate): + """ + Download the zip file. + """ + self.check_slug_candidate(slug_candidate) + # Deny new downloads if "Stop After First Download" is checked and there is + # currently a download + deny_download = not self.stay_open and self.download_in_progress + if deny_download: + r = make_response(render_template('denied.html')) + return self.add_security_headers(r) -def debug_mode(): - """ - Turn on debugging mode, which will log flask errors to a debug file. - """ - temp_dir = tempfile.gettempdir() - log_handler = logging.FileHandler( - os.path.join(temp_dir, 'onionshare_server.log')) - log_handler.setLevel(logging.WARNING) - app.logger.addHandler(log_handler) + # each download has a unique id + download_id = self.download_count + self.download_count += 1 + # prepare some variables to use inside generate() function below + # which is outside of the request context + shutdown_func = request.environ.get('werkzeug.server.shutdown') + path = request.path -def check_slug_candidate(slug_candidate, slug_compare=None): - if not slug_compare: - slug_compare = slug - if not hmac.compare_digest(slug_compare, slug_candidate): - abort(404) + # tell GUI the download started + self.add_request(self.REQUEST_DOWNLOAD, path, {'id': download_id}) + dirname = os.path.dirname(self.zip_filename) + basename = os.path.basename(self.zip_filename) -# If "Stop After First Download" is checked (stay_open == False), only allow -# one download at a time. -download_in_progress = False + def generate(): + # The user hasn't canceled the download + self.client_cancel = False -done = False + # Starting a new download + if not self.stay_open: + self.download_in_progress = True -@app.route("/") -def index(slug_candidate): - """ - Render the template for the onionshare landing page. - """ - check_slug_candidate(slug_candidate) + chunk_size = 102400 # 100kb - add_request(REQUEST_LOAD, request.path) + fp = open(self.zip_filename, 'rb') + self.done = False + canceled = False + while not self.done: + # The user has canceled the download, so stop serving the file + if self.client_cancel: + self.add_request(self.REQUEST_CANCELED, path, {'id': download_id}) + break - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - global stay_open, download_in_progress - deny_download = not stay_open and download_in_progress - if deny_download: - r = make_response(render_template_string( - open(common.get_resource_path('html/denied.html')).read(), - favicon_b64=favicon_b64 - )) - for header, value in security_headers: - r.headers.set(header, value) - return r + chunk = fp.read(chunk_size) + if chunk == b'': + self.done = True + else: + try: + yield chunk - # If download is allowed to continue, serve download page + # tell GUI the progress + downloaded_bytes = fp.tell() + percent = (1.0 * downloaded_bytes / self.zip_filesize) * 100 - r = make_response(render_template_string( - open(common.get_resource_path('html/index.html')).read(), - favicon_b64=favicon_b64, - logo_b64=logo_b64, - folder_b64=folder_b64, - file_b64=file_b64, - slug=slug, - file_info=file_info, - filename=os.path.basename(zip_filename), - filesize=zip_filesize, - filesize_human=common.human_readable_filesize(zip_filesize))) - for header, value in security_headers: - r.headers.set(header, value) - return r + # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) + if not self.gui_mode or self.common.platform == 'Linux' or self.common.platform == 'BSD': + sys.stdout.write( + "\r{0:s}, {1:.2f}% ".format(self.common.human_readable_filesize(downloaded_bytes), percent)) + sys.stdout.flush() + self.add_request(self.REQUEST_PROGRESS, path, {'id': download_id, 'bytes': downloaded_bytes}) + self.done = False + except: + # looks like the download was canceled + self.done = True + canceled = True -# If the client closes the OnionShare window while a download is in progress, -# it should immediately stop serving the file. The client_cancel global is -# used to tell the download function that the client is canceling the download. -client_cancel = False + # tell the GUI the download has canceled + self.add_request(self.REQUEST_CANCELED, path, {'id': download_id}) + fp.close() -@app.route("//download") -def download(slug_candidate): - """ - Download the zip file. - """ - check_slug_candidate(slug_candidate) + if self.common.platform != 'Darwin': + sys.stdout.write("\n") - # Deny new downloads if "Stop After First Download" is checked and there is - # currently a download - global stay_open, download_in_progress, done - deny_download = not stay_open and download_in_progress - if deny_download: - r = make_response(render_template_string( - open(common.get_resource_path('html/denied.html')).read(), - favicon_b64=favicon_b64 - )) - for header,value in security_headers: - r.headers.set(header, value) - return r + # Download is finished + if not self.stay_open: + self.download_in_progress = False - global download_count + # Close the server, if necessary + if not self.stay_open and not canceled: + print(strings._("closing_automatically")) + if shutdown_func is None: + raise RuntimeError('Not running with the Werkzeug Server') + shutdown_func() - # each download has a unique id - download_id = download_count - download_count += 1 + r = Response(generate()) + r.headers.set('Content-Length', self.zip_filesize) + r.headers.set('Content-Disposition', 'attachment', filename=basename) + r = self.add_security_headers(r) + # guess content type + (content_type, _) = mimetypes.guess_type(basename, strict=False) + if content_type is not None: + r.headers.set('Content-Type', content_type) + return r - # prepare some variables to use inside generate() function below - # which is outside of the request context - shutdown_func = request.environ.get('werkzeug.server.shutdown') - path = request.path + def receive_routes(self): + """ + The web app routes for sharing files + """ + @self.app.route("/") + def index(slug_candidate): + self.check_slug_candidate(slug_candidate) - # tell GUI the download started - add_request(REQUEST_DOWNLOAD, path, {'id': download_id}) + r = make_response(render_template( + 'receive.html', + slug=self.slug)) + return self.add_security_headers(r) - dirname = os.path.dirname(zip_filename) - basename = os.path.basename(zip_filename) + @self.app.route("//upload", methods=['POST']) + def upload(slug_candidate): + self.check_slug_candidate(slug_candidate) - def generate(): - # The user hasn't canceled the download - global client_cancel, gui_mode - client_cancel = False + files = request.files.getlist('file[]') + filenames = [] + for f in files: + if f.filename != '': + # Automatically rename the file, if a file of the same name already exists + filename = secure_filename(f.filename) + filenames.append(filename) + local_path = os.path.join(self.common.settings.get('downloads_dir'), filename) + if os.path.exists(local_path): + if '.' in filename: + # Add "-i", e.g. change "foo.txt" to "foo-2.txt" + parts = filename.split('.') + name = parts[:-1] + ext = parts[-1] - # Starting a new download - global stay_open, download_in_progress, done - if not stay_open: - download_in_progress = True + i = 2 + valid = False + while not valid: + new_filename = '{}-{}.{}'.format('.'.join(name), i, ext) + local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) + if os.path.exists(local_path): + i += 1 + else: + valid = True + else: + # If no extension, just add "-i", e.g. change "foo" to "foo-2" + i = 2 + valid = False + while not valid: + new_filename = '{}-{}'.format(filename, i) + local_path = os.path.join(self.common.settings.get('downloads_dir'), new_filename) + if os.path.exists(local_path): + i += 1 + else: + valid = True - chunk_size = 102400 # 100kb + self.common.log('Web', 'receive_routes', '/upload, uploaded {}, saving to {}'.format(f.filename, local_path)) + print(strings._('receive_mode_received_file').format(local_path)) + f.save(local_path) - fp = open(zip_filename, 'rb') - done = False - canceled = False - while not done: - # The user has canceled the download, so stop serving the file - if client_cancel: - add_request(REQUEST_CANCELED, path, {'id': download_id}) - break - - chunk = fp.read(chunk_size) - if chunk == b'': - done = True + # Note that flash strings are on English, and not translated, on purpose, + # to avoid leaking the locale of the OnionShare user + if len(filenames) == 0: + flash('No files uploaded') else: - try: - yield chunk + for filename in filenames: + flash('Uploaded {}'.format(filename)) - # tell GUI the progress - downloaded_bytes = fp.tell() - percent = (1.0 * downloaded_bytes / zip_filesize) * 100 + return redirect('/{}'.format(slug_candidate)) - # only output to stdout if running onionshare in CLI mode, or if using Linux (#203, #304) - plat = common.get_platform() - if not gui_mode or plat == 'Linux' or plat == 'BSD': - sys.stdout.write( - "\r{0:s}, {1:.2f}% ".format(common.human_readable_filesize(downloaded_bytes), percent)) - sys.stdout.flush() + @self.app.route("//close", methods=['POST']) + def close(slug_candidate): + self.check_slug_candidate(slug_candidate) + self.force_shutdown() + r = make_response(render_template('closed.html')) + return self.add_security_headers(r) - add_request(REQUEST_PROGRESS, path, {'id': download_id, 'bytes': downloaded_bytes}) - done = False - except: - # looks like the download was canceled - done = True - canceled = True + def common_routes(self): + """ + Common web app routes between sending and receiving + """ + @self.app.errorhandler(404) + def page_not_found(e): + """ + 404 error page. + """ + self.add_request(self.REQUEST_OTHER, request.path) - # tell the GUI the download has canceled - add_request(REQUEST_CANCELED, path, {'id': download_id}) + if request.path != '/favicon.ico': + self.error404_count += 1 + if self.error404_count == 20: + self.add_request(self.REQUEST_RATE_LIMIT, request.path) + self.force_shutdown() + print(strings._('error_rate_limit')) - fp.close() + r = make_response(render_template('404.html'), 404) + return self.add_security_headers(r) - if common.get_platform() != 'Darwin': - sys.stdout.write("\n") + @self.app.route("//shutdown") + def shutdown(slug_candidate): + """ + Stop the flask web server, from the context of an http request. + """ + self.check_slug_candidate(slug_candidate, self.shutdown_slug) + self.force_shutdown() + return "" - # Download is finished - if not stay_open: - download_in_progress = False + def add_security_headers(self, r): + """ + Add security headers to a request + """ + for header, value in self.security_headers: + r.headers.set(header, value) + return r - # Close the server, if necessary - if not stay_open and not canceled: - print(strings._("closing_automatically")) - if shutdown_func is None: - raise RuntimeError('Not running with the Werkzeug Server') - shutdown_func() + def set_file_info(self, filenames, processed_size_callback=None): + """ + Using the list of filenames being shared, fill in details that the web + page will need to display. This includes zipping up the file in order to + get the zip file's name and size. + """ + # build file info list + self.file_info = {'files': [], 'dirs': []} + for filename in filenames: + info = { + 'filename': filename, + 'basename': os.path.basename(filename.rstrip('/')) + } + if os.path.isfile(filename): + info['size'] = os.path.getsize(filename) + info['size_human'] = self.common.human_readable_filesize(info['size']) + self.file_info['files'].append(info) + if os.path.isdir(filename): + info['size'] = self.common.dir_size(filename) + info['size_human'] = self.common.human_readable_filesize(info['size']) + self.file_info['dirs'].append(info) + self.file_info['files'] = sorted(self.file_info['files'], key=lambda k: k['basename']) + self.file_info['dirs'] = sorted(self.file_info['dirs'], key=lambda k: k['basename']) - r = Response(generate()) - r.headers.set('Content-Length', zip_filesize) - r.headers.set('Content-Disposition', 'attachment', filename=basename) - for header,value in security_headers: - r.headers.set(header, value) - # guess content type - (content_type, _) = mimetypes.guess_type(basename, strict=False) - if content_type is not None: - r.headers.set('Content-Type', content_type) - return r + # zip up the files and folders + z = ZipWriter(self.common, processed_size_callback=processed_size_callback) + for info in self.file_info['files']: + z.add_file(info['filename']) + for info in self.file_info['dirs']: + z.add_dir(info['filename']) + z.close() + self.zip_filename = z.zip_filename + self.zip_filesize = os.path.getsize(self.zip_filename) + def _safe_select_jinja_autoescape(self, filename): + if filename is None: + return True + return filename.endswith(('.html', '.htm', '.xml', '.xhtml')) -@app.errorhandler(404) -def page_not_found(e): - """ - 404 error page. - """ - add_request(REQUEST_OTHER, request.path) + def add_request(self, request_type, path, data=None): + """ + Add a request to the queue, to communicate with the GUI. + """ + self.q.put({ + 'type': request_type, + 'path': path, + 'data': data + }) - global error404_count - if request.path != '/favicon.ico': - error404_count += 1 - if error404_count == 20: - add_request(REQUEST_RATE_LIMIT, request.path) - force_shutdown() - print(strings._('error_rate_limit')) + def generate_slug(self, persistent_slug=''): + if persistent_slug: + self.slug = persistent_slug + else: + self.slug = self.common.build_slug() - r = make_response(render_template_string( - open(common.get_resource_path('html/404.html')).read(), - favicon_b64=favicon_b64 - ), 404) - for header, value in security_headers: - r.headers.set(header, value) - return r + def debug_mode(self): + """ + Turn on debugging mode, which will log flask errors to a debug file. + """ + temp_dir = tempfile.gettempdir() + log_handler = logging.FileHandler( + os.path.join(temp_dir, 'onionshare_server.log')) + log_handler.setLevel(logging.WARNING) + self.app.logger.addHandler(log_handler) + def check_slug_candidate(self, slug_candidate, slug_compare=None): + if not slug_compare: + slug_compare = self.slug + if not hmac.compare_digest(slug_compare, slug_candidate): + abort(404) -# shutting down the server only works within the context of flask, so the easiest way to do it is over http -shutdown_slug = common.random_string(16) + def force_shutdown(self): + """ + Stop the flask web server, from the context of the flask app. + """ + # shutdown the flask service + func = request.environ.get('werkzeug.server.shutdown') + if func is None: + raise RuntimeError('Not running with the Werkzeug Server') + func() + def start(self, port, stay_open=False, persistent_slug=''): + """ + Start the flask web server. + """ + self.generate_slug(persistent_slug) -@app.route("//shutdown") -def shutdown(slug_candidate): - """ - Stop the flask web server, from the context of an http request. - """ - check_slug_candidate(slug_candidate, shutdown_slug) - force_shutdown() - return "" + self.stay_open = stay_open + # In Whonix, listen on 0.0.0.0 instead of 127.0.0.1 (#220) + if os.path.exists('/usr/share/anon-ws-base-files/workstation'): + host = '0.0.0.0' + else: + host = '127.0.0.1' -def force_shutdown(): - """ - Stop the flask web server, from the context of the flask app. - """ - # shutdown the flask service - func = request.environ.get('werkzeug.server.shutdown') - if func is None: - raise RuntimeError('Not running with the Werkzeug Server') - func() + self.app.run(host=host, port=port, threaded=True) + def stop(self, port): + """ + Stop the flask web server by loading /shutdown. + """ -def start(port, stay_open=False, persistent_slug=''): - """ - Start the flask web server. - """ - generate_slug(persistent_slug) + # If the user cancels the download, let the download function know to stop + # serving the file + self.client_cancel = True - set_stay_open(stay_open) - - # In Whonix, listen on 0.0.0.0 instead of 127.0.0.1 (#220) - if os.path.exists('/usr/share/anon-ws-base-files/workstation'): - host = '0.0.0.0' - else: - host = '127.0.0.1' - - app.run(host=host, port=port, threaded=True) - - -def stop(port): - """ - Stop the flask web server by loading /shutdown. - """ - - # If the user cancels the download, let the download function know to stop - # serving the file - global client_cancel - client_cancel = True - - # to stop flask, load http://127.0.0.1://shutdown - try: - s = socket.socket() - s.connect(('127.0.0.1', port)) - s.sendall('GET /{0:s}/shutdown HTTP/1.1\r\n\r\n'.format(shutdown_slug)) - except: + # to stop flask, load http://127.0.0.1://shutdown try: - urlopen('http://127.0.0.1:{0:d}/{1:s}/shutdown'.format(port, shutdown_slug)).read() + s = socket.socket() + s.connect(('127.0.0.1', port)) + s.sendall('GET /{0:s}/shutdown HTTP/1.1\r\n\r\n'.format(self.shutdown_slug)) except: - pass + try: + urlopen('http://127.0.0.1:{0:d}/{1:s}/shutdown'.format(port, self.shutdown_slug)).read() + except: + pass + + +class ZipWriter(object): + """ + ZipWriter accepts files and directories and compresses them into a zip file + with. If a zip_filename is not passed in, it will use the default onionshare + filename. + """ + def __init__(self, common, zip_filename=None, processed_size_callback=None): + self.common = common + + if zip_filename: + self.zip_filename = zip_filename + else: + self.zip_filename = '{0:s}/onionshare_{1:s}.zip'.format(tempfile.mkdtemp(), self.common.random_string(4, 6)) + + self.z = zipfile.ZipFile(self.zip_filename, 'w', allowZip64=True) + self.processed_size_callback = processed_size_callback + if self.processed_size_callback is None: + self.processed_size_callback = lambda _: None + self._size = 0 + self.processed_size_callback(self._size) + + def add_file(self, filename): + """ + Add a file to the zip archive. + """ + self.z.write(filename, os.path.basename(filename), zipfile.ZIP_DEFLATED) + self._size += os.path.getsize(filename) + self.processed_size_callback(self._size) + + def add_dir(self, filename): + """ + Add a directory, and all of its children, to the zip archive. + """ + dir_to_strip = os.path.dirname(filename.rstrip('/'))+'/' + for dirpath, dirnames, filenames in os.walk(filename): + for f in filenames: + full_filename = os.path.join(dirpath, f) + if not os.path.islink(full_filename): + arc_filename = full_filename[len(dir_to_strip):] + self.z.write(full_filename, arc_filename, zipfile.ZIP_DEFLATED) + self._size += os.path.getsize(full_filename) + self.processed_size_callback(self._size) + + def close(self): + """ + Close the zip archive. + """ + self.z.close() + + +class ReceiveModeWSGIMiddleware(object): + """ + Custom WSGI middleware in order to attach the Web object to environ, so + ReceiveModeRequest can access it. + """ + def __init__(self, app, web): + self.app = app + self.web = web + + def __call__(self, environ, start_response): + environ['web'] = self.web + return self.app(environ, start_response) + +class ReceiveModeTemporaryFile(object): + """ + A custom TemporaryFile that tells ReceiveModeRequest every time data gets + written to it, in order to track the progress of uploads. + """ + def __init__(self, filename, update_func): + self.onionshare_filename = filename + self.onionshare_update_func = update_func + + # Create a temporary file + self.f = tempfile.TemporaryFile('wb+') + + # Make all the file-like methods and attributes actually access the + # TemporaryFile, except for write + attrs = ['close', 'closed', 'detach', 'fileno', 'flush', 'isatty', 'mode', + 'name', 'peek', 'raw', 'read', 'read1', 'readable', 'readinto', + 'readinto1', 'readline', 'readlines', 'seek', 'seekable', 'tell', + 'truncate', 'writable', 'writelines'] + for attr in attrs: + setattr(self, attr, getattr(self.f, attr)) + + def write(self, b): + """ + Custom write method that calls out to onionshare_update_func + """ + bytes_written = self.f.write(b) + self.onionshare_update_func(self.onionshare_filename, bytes_written) + + +class ReceiveModeRequest(Request): + """ + A custom flask Request object that keeps track of how much data has been + uploaded for each file, for receive mode. + """ + def __init__(self, environ, populate_request=True, shallow=False): + super(ReceiveModeRequest, self).__init__(environ, populate_request, shallow) + self.web = environ['web'] + + # A dictionary that maps filenames to the bytes uploaded so far + self.onionshare_progress = {} + + def _get_file_stream(self, total_content_length, content_type, filename=None, content_length=None): + """ + This gets called for each file that gets uploaded, and returns an file-like + writable stream. + """ + if len(self.onionshare_progress) > 0: + print('') + self.onionshare_progress[filename] = 0 + return ReceiveModeTemporaryFile(filename, self.onionshare_update_func) + + def close(self): + """ + When closing the request, print a newline if this was a file upload. + """ + super(ReceiveModeRequest, self).close() + if len(self.onionshare_progress) > 0: + print('') + + def onionshare_update_func(self, filename, length): + """ + Keep track of the bytes uploaded so far for all files. + """ + self.onionshare_progress[filename] += length + print('{} - {} '.format(self.web.common.human_readable_filesize(self.onionshare_progress[filename]), filename), end='\r') diff --git a/onionshare_gui/__init__.py b/onionshare_gui/__init__.py index c1d84440..11a5999c 100644 --- a/onionshare_gui/__init__.py +++ b/onionshare_gui/__init__.py @@ -22,10 +22,11 @@ import os, sys, platform, argparse from .alert import Alert from PyQt5 import QtCore, QtWidgets -from onionshare import strings, common, web +from onionshare import strings +from onionshare.common import Common +from onionshare.web import Web from onionshare.onion import Onion from onionshare.onionshare import OnionShare -from onionshare.settings import Settings from .onionshare_gui import OnionShareGui @@ -34,9 +35,8 @@ class Application(QtWidgets.QApplication): This is Qt's QApplication class. It has been overridden to support threads and the quick keyboard shortcut. """ - def __init__(self): - system = common.get_platform() - if system == 'Linux' or system == 'BSD': + def __init__(self, common): + if common.platform == 'Linux' or common.platform == 'BSD': self.setAttribute(QtCore.Qt.AA_X11InitThreads, True) QtWidgets.QApplication.__init__(self, sys.argv) self.installEventFilter(self) @@ -53,12 +53,14 @@ def main(): """ The main() function implements all of the logic that the GUI version of onionshare uses. """ + common = Common() + strings.load_strings(common) - print(strings._('version_string').format(common.get_version())) + print(strings._('version_string').format(common.version)) # Start the Qt app global qtapp - qtapp = Application() + qtapp = Application(common) # Parse arguments parser = argparse.ArgumentParser(formatter_class=lambda prog: argparse.HelpFormatter(prog,max_help_position=48)) @@ -83,32 +85,32 @@ def main(): debug = bool(args.debug) # Debug mode? - if debug: - common.set_debug(debug) - web.debug_mode() + common.debug = debug # Validation if filenames: valid = True for filename in filenames: if not os.path.isfile(filename) and not os.path.isdir(filename): - Alert(strings._("not_a_file", True).format(filename)) + Alert(common, strings._("not_a_file", True).format(filename)) valid = False if not os.access(filename, os.R_OK): - Alert(strings._("not_a_readable_file", True).format(filename)) + Alert(common, strings._("not_a_readable_file", True).format(filename)) valid = False if not valid: sys.exit() + # Create the Web object + web = Web(common, stay_open, True) + # Start the Onion - onion = Onion() + onion = Onion(common) # Start the OnionShare app - web.set_stay_open(stay_open) - app = OnionShare(onion, local_only, stay_open, shutdown_timeout) + app = OnionShare(common, onion, local_only, stay_open, shutdown_timeout) # Launch the gui - gui = OnionShareGui(onion, qtapp, app, filenames, config, local_only) + gui = OnionShareGui(common, web, onion, qtapp, app, filenames, config, local_only) # Clean up when app quits def shutdown(): diff --git a/onionshare_gui/alert.py b/onionshare_gui/alert.py index 814ff786..981225c6 100644 --- a/onionshare_gui/alert.py +++ b/onionshare_gui/alert.py @@ -19,18 +19,19 @@ along with this program. If not, see . """ from PyQt5 import QtCore, QtWidgets, QtGui -from onionshare import common - class Alert(QtWidgets.QMessageBox): """ An alert box dialog. """ - def __init__(self, message, icon=QtWidgets.QMessageBox.NoIcon, buttons=QtWidgets.QMessageBox.Ok, autostart=True): + def __init__(self, common, message, icon=QtWidgets.QMessageBox.NoIcon, buttons=QtWidgets.QMessageBox.Ok, autostart=True): super(Alert, self).__init__(None) - common.log('Alert', '__init__') + + self.common = common + + self.common.log('Alert', '__init__') self.setWindowTitle("OnionShare") - self.setWindowIcon(QtGui.QIcon(common.get_resource_path('images/logo.png'))) + self.setWindowIcon(QtGui.QIcon(self.common.get_resource_path('images/logo.png'))) self.setText(message) self.setIcon(icon) self.setStandardButtons(buttons) diff --git a/onionshare_gui/downloads.py b/onionshare_gui/downloads.py index 5f82e8ba..0e85d33f 100644 --- a/onionshare_gui/downloads.py +++ b/onionshare_gui/downloads.py @@ -21,11 +21,13 @@ import time from PyQt5 import QtCore, QtWidgets, QtGui -from onionshare import strings, common +from onionshare import strings class Download(object): - def __init__(self, download_id, total_bytes): + def __init__(self, common, download_id, total_bytes): + self.common = common + self.download_id = download_id self.started = time.time() self.total_bytes = total_bytes @@ -64,7 +66,7 @@ class Download(object): self.progress_bar.setValue(downloaded_bytes) if downloaded_bytes == self.progress_bar.total_bytes: pb_fmt = strings._('gui_download_progress_complete').format( - common.format_seconds(time.time() - self.started)) + self.common.format_seconds(time.time() - self.started)) else: elapsed = time.time() - self.started if elapsed < 10: @@ -72,10 +74,10 @@ class Download(object): # This prevents a "Windows copy dialog"-esque experience at # the beginning of the download. pb_fmt = strings._('gui_download_progress_starting').format( - common.human_readable_filesize(downloaded_bytes)) + self.common.human_readable_filesize(downloaded_bytes)) else: pb_fmt = strings._('gui_download_progress_eta').format( - common.human_readable_filesize(downloaded_bytes), + self.common.human_readable_filesize(downloaded_bytes), self.estimated_time_remaining) self.progress_bar.setFormat(pb_fmt) @@ -85,7 +87,7 @@ class Download(object): @property def estimated_time_remaining(self): - return common.estimated_time_remaining(self.downloaded_bytes, + return self.common.estimated_time_remaining(self.downloaded_bytes, self.total_bytes, self.started) @@ -95,8 +97,11 @@ class Downloads(QtWidgets.QWidget): The downloads chunk of the GUI. This lists all of the active download progress bars. """ - def __init__(self): + def __init__(self, common): super(Downloads, self).__init__() + + self.common = common + self.downloads = {} self.downloads_container = QtWidgets.QScrollArea() @@ -128,7 +133,7 @@ class Downloads(QtWidgets.QWidget): Add a new download progress bar. """ # add it to the list - download = Download(download_id, total_bytes) + download = Download(self.common, download_id, total_bytes) self.downloads[download_id] = download self.downloads_layout.addWidget(download.progress_bar) diff --git a/onionshare_gui/file_selection.py b/onionshare_gui/file_selection.py index 29bcc592..fbc4995b 100644 --- a/onionshare_gui/file_selection.py +++ b/onionshare_gui/file_selection.py @@ -21,21 +21,24 @@ import os from PyQt5 import QtCore, QtWidgets, QtGui from .alert import Alert -from onionshare import strings, common +from onionshare import strings class DropHereLabel(QtWidgets.QLabel): """ When there are no files or folders in the FileList yet, display the 'drop files here' message and graphic. """ - def __init__(self, parent, image=False): + def __init__(self, common, parent, image=False): self.parent = parent super(DropHereLabel, self).__init__(parent=parent) + + self.common = common + self.setAcceptDrops(True) self.setAlignment(QtCore.Qt.AlignCenter) if image: - self.setPixmap(QtGui.QPixmap.fromImage(QtGui.QImage(common.get_resource_path('images/logo_transparent.png')))) + self.setPixmap(QtGui.QPixmap.fromImage(QtGui.QImage(self.common.get_resource_path('images/logo_transparent.png')))) else: self.setText(strings._('gui_drag_and_drop', True)) self.setStyleSheet('color: #999999;') @@ -53,9 +56,12 @@ class DropCountLabel(QtWidgets.QLabel): While dragging files over the FileList, this counter displays the number of files you're dragging. """ - def __init__(self, parent): + def __init__(self, common, parent): self.parent = parent super(DropCountLabel, self).__init__(parent=parent) + + self.common = common + self.setAcceptDrops(True) self.setAlignment(QtCore.Qt.AlignCenter) self.setText(strings._('gui_drag_and_drop', True)) @@ -74,16 +80,19 @@ class FileList(QtWidgets.QListWidget): files_dropped = QtCore.pyqtSignal() files_updated = QtCore.pyqtSignal() - def __init__(self, parent=None): + def __init__(self, common, parent=None): super(FileList, self).__init__(parent) + + self.common = common + self.setAcceptDrops(True) self.setIconSize(QtCore.QSize(32, 32)) self.setSortingEnabled(True) self.setMinimumHeight(205) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - self.drop_here_image = DropHereLabel(self, True) - self.drop_here_text = DropHereLabel(self, False) - self.drop_count = DropCountLabel(self) + self.drop_here_image = DropHereLabel(self.common, self, True) + self.drop_here_text = DropHereLabel(self.common, self, False) + self.drop_count = DropCountLabel(self.common, self) self.resizeEvent(None) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) @@ -206,7 +215,7 @@ class FileList(QtWidgets.QListWidget): if filename not in filenames: if not os.access(filename, os.R_OK): - Alert(strings._("not_a_readable_file", True).format(filename)) + Alert(self.common, strings._("not_a_readable_file", True).format(filename)) return fileinfo = QtCore.QFileInfo(filename) @@ -215,10 +224,10 @@ class FileList(QtWidgets.QListWidget): if os.path.isfile(filename): size_bytes = fileinfo.size() - size_readable = common.human_readable_filesize(size_bytes) + size_readable = self.common.human_readable_filesize(size_bytes) else: - size_bytes = common.dir_size(filename) - size_readable = common.human_readable_filesize(size_bytes) + size_bytes = self.common.dir_size(filename) + size_readable = self.common.human_readable_filesize(size_bytes) # Create a new item item = QtWidgets.QListWidgetItem() @@ -245,7 +254,7 @@ class FileList(QtWidgets.QListWidget): item.item_button = QtWidgets.QPushButton() item.item_button.setDefault(False) item.item_button.setFlat(True) - item.item_button.setIcon( QtGui.QIcon(common.get_resource_path('images/file_delete.png')) ) + item.item_button.setIcon( QtGui.QIcon(self.common.get_resource_path('images/file_delete.png')) ) item.item_button.clicked.connect(delete_item) item.item_button.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) @@ -277,12 +286,15 @@ class FileSelection(QtWidgets.QVBoxLayout): The list of files and folders in the GUI, as well as buttons to add and delete the files and folders. """ - def __init__(self): + def __init__(self, common): super(FileSelection, self).__init__() + + self.common = common + self.server_on = False # File list - self.file_list = FileList() + self.file_list = FileList(self.common) self.file_list.itemSelectionChanged.connect(self.update) self.file_list.files_dropped.connect(self.update) self.file_list.files_updated.connect(self.update) diff --git a/onionshare_gui/onionshare_gui.py b/onionshare_gui/onionshare_gui.py index 0bb06a0c..d5a0889a 100644 --- a/onionshare_gui/onionshare_gui.py +++ b/onionshare_gui/onionshare_gui.py @@ -17,11 +17,14 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os, threading, time +import os +import threading +import time +import queue from PyQt5 import QtCore, QtWidgets, QtGui -from onionshare import strings, common, web -from onionshare.settings import Settings +from onionshare import strings, common +from onionshare.common import Common, ShutdownTimer from onionshare.onion import * from .tor_connection_dialog import TorConnectionDialog @@ -43,35 +46,36 @@ class OnionShareGui(QtWidgets.QMainWindow): starting_server_step3 = QtCore.pyqtSignal() starting_server_error = QtCore.pyqtSignal(str) - def __init__(self, onion, qtapp, app, filenames, config=False, local_only=False): + def __init__(self, common, web, onion, qtapp, app, filenames, config=False, local_only=False): super(OnionShareGui, self).__init__() + self.common = common + self.common.log('OnionShareGui', '__init__') + self._initSystemTray() - common.log('OnionShareGui', '__init__') - + self.web = web self.onion = onion self.qtapp = qtapp self.app = app self.local_only = local_only self.setWindowTitle('OnionShare') - self.setWindowIcon(QtGui.QIcon(common.get_resource_path('images/logo.png'))) + self.setWindowIcon(QtGui.QIcon(self.common.get_resource_path('images/logo.png'))) self.setMinimumWidth(430) # Load settings self.config = config - self.settings = Settings(self.config) - self.settings.load() + self.common.load_settings(self.config) # File selection - self.file_selection = FileSelection() + self.file_selection = FileSelection(self.common) if filenames: for filename in filenames: self.file_selection.file_list.add_file(filename) # Server status - self.server_status = ServerStatus(self.qtapp, self.app, web, self.file_selection, self.settings) + self.server_status = ServerStatus(self.common, self.qtapp, self.app, self.web, self.file_selection) self.server_status.server_started.connect(self.file_selection.server_started) self.server_status.server_started.connect(self.start_server) self.server_status.server_started.connect(self.update_server_status_indicator) @@ -103,7 +107,7 @@ class OnionShareGui(QtWidgets.QMainWindow): self.filesize_warning.hide() # Downloads - self.downloads = Downloads() + self.downloads = Downloads(self.common) self.new_download = False self.downloads_in_progress = 0 self.downloads_completed = 0 @@ -114,7 +118,7 @@ class OnionShareGui(QtWidgets.QMainWindow): self.info_label.setStyleSheet('QLabel { font-size: 12px; color: #666666; }') self.info_show_downloads = QtWidgets.QToolButton() - self.info_show_downloads.setIcon(QtGui.QIcon(common.get_resource_path('images/download_window_gray.png'))) + self.info_show_downloads.setIcon(QtGui.QIcon(self.common.get_resource_path('images/download_window_gray.png'))) self.info_show_downloads.setCheckable(True) self.info_show_downloads.toggled.connect(self.downloads_toggled) self.info_show_downloads.setToolTip(strings._('gui_downloads_window_tooltip', True)) @@ -143,13 +147,13 @@ class OnionShareGui(QtWidgets.QMainWindow): self.settings_button.setDefault(False) self.settings_button.setFlat(True) self.settings_button.setFixedWidth(40) - self.settings_button.setIcon( QtGui.QIcon(common.get_resource_path('images/settings.png')) ) + self.settings_button.setIcon( QtGui.QIcon(self.common.get_resource_path('images/settings.png')) ) self.settings_button.clicked.connect(self.open_settings) # Server status indicator on the status bar - self.server_status_image_stopped = QtGui.QImage(common.get_resource_path('images/server_stopped.png')) - self.server_status_image_working = QtGui.QImage(common.get_resource_path('images/server_working.png')) - self.server_status_image_started = QtGui.QImage(common.get_resource_path('images/server_started.png')) + self.server_status_image_stopped = QtGui.QImage(self.common.get_resource_path('images/server_stopped.png')) + self.server_status_image_working = QtGui.QImage(self.common.get_resource_path('images/server_working.png')) + self.server_status_image_started = QtGui.QImage(self.common.get_resource_path('images/server_started.png')) self.server_status_image_label = QtWidgets.QLabel() self.server_status_image_label.setFixedWidth(20) self.server_status_label = QtWidgets.QLabel() @@ -216,7 +220,7 @@ class OnionShareGui(QtWidgets.QMainWindow): self.timer.timeout.connect(self.check_for_requests) # Start the "Connecting to Tor" dialog, which calls onion.connect() - tor_con = TorConnectionDialog(self.qtapp, self.settings, self.onion) + tor_con = TorConnectionDialog(self.common, self.qtapp, self.onion) tor_con.canceled.connect(self._tor_connection_canceled) tor_con.open_settings.connect(self._tor_connection_open_settings) if not self.local_only: @@ -240,7 +244,7 @@ class OnionShareGui(QtWidgets.QMainWindow): for index in range(self.file_selection.file_list.count()): item = self.file_selection.file_list.item(index) total_size_bytes += item.size_bytes - total_size_readable = common.human_readable_filesize(total_size_bytes) + total_size_readable = self.common.human_readable_filesize(total_size_bytes) if file_count > 1: self.info_label.setText(strings._('gui_file_info', True).format(file_count, total_size_readable)) @@ -255,7 +259,7 @@ class OnionShareGui(QtWidgets.QMainWindow): self.adjustSize() def update_server_status_indicator(self): - common.log('OnionShareGui', 'update_server_status_indicator') + self.common.log('OnionShareGui', 'update_server_status_indicator') # Set the status image if self.server_status.status == self.server_status.STATUS_STOPPED: @@ -269,8 +273,6 @@ class OnionShareGui(QtWidgets.QMainWindow): self.server_status_label.setText(strings._('gui_status_indicator_started', True)) def _initSystemTray(self): - system = common.get_platform() - menu = QtWidgets.QMenu() self.settingsAction = menu.addAction(strings._('gui_settings_window_title', True)) self.settingsAction.triggered.connect(self.open_settings) @@ -281,10 +283,10 @@ class OnionShareGui(QtWidgets.QMainWindow): self.systemTray = QtWidgets.QSystemTrayIcon(self) # The convention is Mac systray icons are always grayscale - if system == 'Darwin': - self.systemTray.setIcon(QtGui.QIcon(common.get_resource_path('images/logo_grayscale.png'))) + if self.common.platform == 'Darwin': + self.systemTray.setIcon(QtGui.QIcon(self.common.get_resource_path('images/logo_grayscale.png'))) else: - self.systemTray.setIcon(QtGui.QIcon(common.get_resource_path('images/logo.png'))) + self.systemTray.setIcon(QtGui.QIcon(self.common.get_resource_path('images/logo.png'))) self.systemTray.setContextMenu(menu) self.systemTray.show() @@ -293,10 +295,10 @@ class OnionShareGui(QtWidgets.QMainWindow): If the user cancels before Tor finishes connecting, ask if they want to quit, or open settings. """ - common.log('OnionShareGui', '_tor_connection_canceled') + self.common.log('OnionShareGui', '_tor_connection_canceled') def ask(): - a = Alert(strings._('gui_tor_connection_ask', True), QtWidgets.QMessageBox.Question, buttons=QtWidgets.QMessageBox.NoButton, autostart=False) + a = Alert(self.common, strings._('gui_tor_connection_ask', True), QtWidgets.QMessageBox.Question, buttons=QtWidgets.QMessageBox.NoButton, autostart=False) settings_button = QtWidgets.QPushButton(strings._('gui_tor_connection_ask_open_settings', True)) quit_button = QtWidgets.QPushButton(strings._('gui_tor_connection_ask_quit', True)) a.addButton(settings_button, QtWidgets.QMessageBox.AcceptRole) @@ -306,12 +308,12 @@ class OnionShareGui(QtWidgets.QMainWindow): if a.clickedButton() == settings_button: # Open settings - common.log('OnionShareGui', '_tor_connection_canceled', 'Settings button clicked') + self.common.log('OnionShareGui', '_tor_connection_canceled', 'Settings button clicked') self.open_settings() if a.clickedButton() == quit_button: # Quit - common.log('OnionShareGui', '_tor_connection_canceled', 'Quit button clicked') + self.common.log('OnionShareGui', '_tor_connection_canceled', 'Quit button clicked') # Wait 1ms for the event loop to finish, then quit QtCore.QTimer.singleShot(1, self.qtapp.quit) @@ -323,7 +325,7 @@ class OnionShareGui(QtWidgets.QMainWindow): """ The TorConnectionDialog wants to open the Settings dialog """ - common.log('OnionShareGui', '_tor_connection_open_settings') + self.common.log('OnionShareGui', '_tor_connection_open_settings') # Wait 1ms for the event loop to finish closing the TorConnectionDialog QtCore.QTimer.singleShot(1, self.open_settings) @@ -332,11 +334,11 @@ class OnionShareGui(QtWidgets.QMainWindow): """ Open the SettingsDialog. """ - common.log('OnionShareGui', 'open_settings') + self.common.log('OnionShareGui', 'open_settings') def reload_settings(): - common.log('OnionShareGui', 'open_settings', 'settings have changed, reloading') - self.settings.load() + self.common.log('OnionShareGui', 'open_settings', 'settings have changed, reloading') + self.common.settings.load() # We might've stopped the main requests timer if a Tor connection failed. # If we've reloaded settings, we probably succeeded in obtaining a new # connection. If so, restart the timer. @@ -351,10 +353,10 @@ class OnionShareGui(QtWidgets.QMainWindow): self.info_widget.show() self.status_bar.clearMessage() # If we switched off the shutdown timeout setting, ensure the widget is hidden. - if not self.settings.get('shutdown_timeout'): + if not self.common.settings.get('shutdown_timeout'): self.server_status.shutdown_timeout_container.hide() - d = SettingsDialog(self.onion, self.qtapp, self.config, self.local_only) + d = SettingsDialog(self.common, self.onion, self.qtapp, self.config, self.local_only) d.settings_saved.connect(reload_settings) d.exec_() @@ -366,11 +368,11 @@ class OnionShareGui(QtWidgets.QMainWindow): Start the onionshare server. This uses multiple threads to start the Tor onion server and the web app. """ - common.log('OnionShareGui', 'start_server') + self.common.log('OnionShareGui', 'start_server') self.set_server_active(True) - self.app.set_stealth(self.settings.get('use_stealth')) + self.app.set_stealth(self.common.settings.get('use_stealth')) # Hide and reset the downloads if we have previously shared self.downloads.reset_downloads() @@ -379,9 +381,8 @@ class OnionShareGui(QtWidgets.QMainWindow): self.server_share_status_label.setText('') # Reset web counters - web.download_count = 0 - web.error404_count = 0 - web.set_gui_mode() + self.web.download_count = 0 + self.web.error404_count = 0 # start the onion service in a new thread def start_onion_service(self): @@ -394,17 +395,17 @@ class OnionShareGui(QtWidgets.QMainWindow): return - self.app.stay_open = not self.settings.get('close_after_first_download') + self.app.stay_open = not self.common.settings.get('close_after_first_download') # start onionshare http service in new thread - t = threading.Thread(target=web.start, args=(self.app.port, self.app.stay_open, self.settings.get('slug'))) + t = threading.Thread(target=self.web.start, args=(self.app.port, self.app.stay_open, self.common.settings.get('slug'))) t.daemon = True t.start() # wait for modules in thread to load, preventing a thread-related cx_Freeze crash time.sleep(0.2) - common.log('OnionshareGui', 'start_server', 'Starting an onion thread') - self.t = OnionThread(function=start_onion_service, kwargs={'self': self}) + self.common.log('OnionshareGui', 'start_server', 'Starting an onion thread') + self.t = OnionThread(self.common, function=start_onion_service, kwargs={'self': self}) self.t.daemon = True self.t.start() @@ -412,7 +413,7 @@ class OnionShareGui(QtWidgets.QMainWindow): """ Step 2 in starting the onionshare server. Zipping up files. """ - common.log('OnionShareGui', 'start_server_step2') + self.common.log('OnionShareGui', 'start_server_step2') # add progress bar to the status bar, indicating the compressing of files. self._zip_progress_bar = ZipProgressBar(0) @@ -430,8 +431,8 @@ class OnionShareGui(QtWidgets.QMainWindow): if self._zip_progress_bar != None: self._zip_progress_bar.update_processed_size_signal.emit(x) try: - web.set_file_info(self.filenames, processed_size_callback=_set_processed_size) - self.app.cleanup_filenames.append(web.zip_filename) + self.web.set_file_info(self.filenames, processed_size_callback=_set_processed_size) + self.app.cleanup_filenames.append(self.web.zip_filename) self.starting_server_step3.emit() # done @@ -449,7 +450,7 @@ class OnionShareGui(QtWidgets.QMainWindow): Step 3 in starting the onionshare server. This displays the large filesize warning, if applicable. """ - common.log('OnionShareGui', 'start_server_step3') + self.common.log('OnionShareGui', 'start_server_step3') # Remove zip progress bar if self._zip_progress_bar is not None: @@ -457,17 +458,17 @@ class OnionShareGui(QtWidgets.QMainWindow): self._zip_progress_bar = None # warn about sending large files over Tor - if web.zip_filesize >= 157286400: # 150mb + if self.web.zip_filesize >= 157286400: # 150mb self.filesize_warning.setText(strings._("large_filesize", True)) self.filesize_warning.show() - if self.settings.get('shutdown_timeout'): + if self.common.settings.get('shutdown_timeout'): # Convert the date value to seconds between now and then now = QtCore.QDateTime.currentDateTime() self.timeout = now.secsTo(self.server_status.timeout) # Set the shutdown timeout value if self.timeout > 0: - self.app.shutdown_timer = common.close_after_seconds(self.timeout) + self.app.shutdown_timer = ShutdownTimer(self.common, self.timeout) self.app.shutdown_timer.start() # The timeout has actually already passed since the user clicked Start. Probably the Onion service took too long to start. else: @@ -478,11 +479,11 @@ class OnionShareGui(QtWidgets.QMainWindow): """ If there's an error when trying to start the onion service """ - common.log('OnionShareGui', 'start_server_error') + self.common.log('OnionShareGui', 'start_server_error') self.set_server_active(False) - Alert(error, QtWidgets.QMessageBox.Warning) + Alert(self.common, error, QtWidgets.QMessageBox.Warning) self.server_status.stop_server() if self._zip_progress_bar is not None: self.status_bar.removeWidget(self._zip_progress_bar) @@ -501,11 +502,11 @@ class OnionShareGui(QtWidgets.QMainWindow): """ Stop the onionshare server. """ - common.log('OnionShareGui', 'stop_server') + self.common.log('OnionShareGui', 'stop_server') if self.server_status.status != self.server_status.STATUS_STOPPED: try: - web.stop(self.app.port) + self.web.stop(self.app.port) except: # Probably we had no port to begin with (Onion service didn't start) pass @@ -525,13 +526,12 @@ class OnionShareGui(QtWidgets.QMainWindow): """ Check for updates in a new thread, if enabled. """ - system = common.get_platform() - if system == 'Windows' or system == 'Darwin': - if self.settings.get('use_autoupdate'): + if self.common.platform == 'Windows' or self.common.platform == 'Darwin': + if self.common.settings.get('use_autoupdate'): def update_available(update_url, installed_version, latest_version): - Alert(strings._("update_available", True).format(update_url, installed_version, latest_version)) + Alert(self.common, strings._("update_available", True).format(update_url, installed_version, latest_version)) - self.update_thread = UpdateThread(self.onion, self.config) + self.update_thread = UpdateThread(self.common, self.onion, self.config) self.update_thread.update_available.connect(update_available) self.update_thread.start() @@ -542,7 +542,7 @@ class OnionShareGui(QtWidgets.QMainWindow): if os.path.isfile(filename): total_size += os.path.getsize(filename) if os.path.isdir(filename): - total_size += common.dir_size(filename) + total_size += Common.dir_size(filename) return total_size def check_for_requests(self): @@ -574,34 +574,34 @@ class OnionShareGui(QtWidgets.QMainWindow): done = False while not done: try: - r = web.q.get(False) + r = self.web.q.get(False) events.append(r) - except web.queue.Empty: + except queue.Empty: done = True for event in events: - if event["type"] == web.REQUEST_LOAD: + if event["type"] == self.web.REQUEST_LOAD: self.status_bar.showMessage(strings._('download_page_loaded', True)) - elif event["type"] == web.REQUEST_DOWNLOAD: + elif event["type"] == self.web.REQUEST_DOWNLOAD: self.downloads.no_downloads_label.hide() self.downloads.add_download(event["data"]["id"], web.zip_filesize) self.new_download = True self.downloads_in_progress += 1 self.update_downloads_in_progress(self.downloads_in_progress) - if self.systemTray.supportsMessages() and self.settings.get('systray_notifications'): + if self.systemTray.supportsMessages() and self.common.settings.get('systray_notifications'): self.systemTray.showMessage(strings._('systray_download_started_title', True), strings._('systray_download_started_message', True)) - elif event["type"] == web.REQUEST_RATE_LIMIT: + elif event["type"] == self.web.REQUEST_RATE_LIMIT: self.stop_server() - Alert(strings._('error_rate_limit'), QtWidgets.QMessageBox.Critical) + Alert(self.common, strings._('error_rate_limit'), QtWidgets.QMessageBox.Critical) - elif event["type"] == web.REQUEST_PROGRESS: + elif event["type"] == self.web.REQUEST_PROGRESS: self.downloads.update_download(event["data"]["id"], event["data"]["bytes"]) # is the download complete? - if event["data"]["bytes"] == web.zip_filesize: - if self.systemTray.supportsMessages() and self.settings.get('systray_notifications'): + if event["data"]["bytes"] == self.web.zip_filesize: + if self.systemTray.supportsMessages() and self.common.settings.get('systray_notifications'): self.systemTray.showMessage(strings._('systray_download_completed_title', True), strings._('systray_download_completed_message', True)) # Update the total 'completed downloads' info self.downloads_completed += 1 @@ -611,7 +611,7 @@ class OnionShareGui(QtWidgets.QMainWindow): self.update_downloads_in_progress(self.downloads_in_progress) # close on finish? - if not web.get_stay_open(): + if not self.web.stay_open: self.server_status.stop_server() self.status_bar.clearMessage() self.server_share_status_label.setText(strings._('closing_automatically', True)) @@ -622,27 +622,27 @@ class OnionShareGui(QtWidgets.QMainWindow): self.update_downloads_in_progress(self.downloads_in_progress) - elif event["type"] == web.REQUEST_CANCELED: + elif event["type"] == self.web.REQUEST_CANCELED: self.downloads.cancel_download(event["data"]["id"]) # Update the 'in progress downloads' info self.downloads_in_progress -= 1 self.update_downloads_in_progress(self.downloads_in_progress) - if self.systemTray.supportsMessages() and self.settings.get('systray_notifications'): + if self.systemTray.supportsMessages() and self.common.settings.get('systray_notifications'): self.systemTray.showMessage(strings._('systray_download_canceled_title', True), strings._('systray_download_canceled_message', True)) elif event["path"] != '/favicon.ico': - self.status_bar.showMessage('[#{0:d}] {1:s}: {2:s}'.format(web.error404_count, strings._('other_page_loaded', True), event["path"])) + self.status_bar.showMessage('[#{0:d}] {1:s}: {2:s}'.format(self.web.error404_count, strings._('other_page_loaded', True), event["path"])) # If the auto-shutdown timer has stopped, stop the server if self.server_status.status == self.server_status.STATUS_STARTED: - if self.app.shutdown_timer and self.settings.get('shutdown_timeout'): + if self.app.shutdown_timer and self.common.settings.get('shutdown_timeout'): if self.timeout > 0: now = QtCore.QDateTime.currentDateTime() seconds_remaining = now.secsTo(self.server_status.timeout) self.server_status.server_button.setText(strings._('gui_stop_server_shutdown_timeout', True).format(seconds_remaining)) if not self.app.shutdown_timer.is_alive(): # If there were no attempts to download the share, or all downloads are done, we can stop - if web.download_count == 0 or web.done: + if self.web.download_count == 0 or self.web.done: self.server_status.stop_server() self.status_bar.clearMessage() self.server_share_status_label.setText(strings._('close_on_timeout', True)) @@ -655,7 +655,7 @@ class OnionShareGui(QtWidgets.QMainWindow): """ When the 'Show/hide downloads' button is toggled, show or hide the downloads window. """ - common.log('OnionShareGui', 'toggle_downloads') + self.common.log('OnionShareGui', 'toggle_downloads') if checked: self.downloads.downloads_container.show() else: @@ -665,16 +665,16 @@ class OnionShareGui(QtWidgets.QMainWindow): """ When the URL gets copied to the clipboard, display this in the status bar. """ - common.log('OnionShareGui', 'copy_url') - if self.systemTray.supportsMessages() and self.settings.get('systray_notifications'): + self.common.log('OnionShareGui', 'copy_url') + if self.systemTray.supportsMessages() and self.common.settings.get('systray_notifications'): self.systemTray.showMessage(strings._('gui_copied_url_title', True), strings._('gui_copied_url', True)) def copy_hidservauth(self): """ When the stealth onion service HidServAuth gets copied to the clipboard, display this in the status bar. """ - common.log('OnionShareGui', 'copy_hidservauth') - if self.systemTray.supportsMessages() and self.settings.get('systray_notifications'): + self.common.log('OnionShareGui', 'copy_hidservauth') + if self.systemTray.supportsMessages() and self.common.settings.get('systray_notifications'): self.systemTray.showMessage(strings._('gui_copied_hidservauth_title', True), strings._('gui_copied_hidservauth', True)) def clear_message(self): @@ -701,7 +701,7 @@ class OnionShareGui(QtWidgets.QMainWindow): """ self.update_downloads_completed(0) self.update_downloads_in_progress(0) - self.info_show_downloads.setIcon(QtGui.QIcon(common.get_resource_path('images/download_window_gray.png'))) + self.info_show_downloads.setIcon(QtGui.QIcon(self.common.get_resource_path('images/download_window_gray.png'))) self.downloads.no_downloads_label.show() self.downloads.downloads_container.resize(self.downloads.downloads_container.sizeHint()) @@ -710,9 +710,9 @@ class OnionShareGui(QtWidgets.QMainWindow): Update the 'Downloads completed' info widget. """ if count == 0: - self.info_completed_downloads_image = common.get_resource_path('images/download_completed_none.png') + self.info_completed_downloads_image = self.common.get_resource_path('images/download_completed_none.png') else: - self.info_completed_downloads_image = common.get_resource_path('images/download_completed.png') + self.info_completed_downloads_image = self.common.get_resource_path('images/download_completed.png') self.info_completed_downloads_count.setText(' {1:d}'.format(self.info_completed_downloads_image, count)) self.info_completed_downloads_count.setToolTip(strings._('info_completed_downloads_tooltip', True).format(count)) @@ -721,18 +721,18 @@ class OnionShareGui(QtWidgets.QMainWindow): Update the 'Downloads in progress' info widget. """ if count == 0: - self.info_in_progress_downloads_image = common.get_resource_path('images/download_in_progress_none.png') + self.info_in_progress_downloads_image = self.common.get_resource_path('images/download_in_progress_none.png') else: - self.info_in_progress_downloads_image = common.get_resource_path('images/download_in_progress.png') - self.info_show_downloads.setIcon(QtGui.QIcon(common.get_resource_path('images/download_window_green.png'))) + self.info_in_progress_downloads_image = self.common.get_resource_path('images/download_in_progress.png') + self.info_show_downloads.setIcon(QtGui.QIcon(self.common.get_resource_path('images/download_window_green.png'))) self.info_in_progress_downloads_count.setText(' {1:d}'.format(self.info_in_progress_downloads_image, count)) self.info_in_progress_downloads_count.setToolTip(strings._('info_in_progress_downloads_tooltip', True).format(count)) def closeEvent(self, e): - common.log('OnionShareGui', 'closeEvent') + self.common.log('OnionShareGui', 'closeEvent') try: if self.server_status.status != self.server_status.STATUS_STOPPED: - common.log('OnionShareGui', 'closeEvent, opening warning dialog') + self.common.log('OnionShareGui', 'closeEvent, opening warning dialog') dialog = QtWidgets.QMessageBox() dialog.setWindowTitle(strings._('gui_quit_title', True)) dialog.setText(strings._('gui_quit_warning', True)) @@ -817,9 +817,12 @@ class OnionThread(QtCore.QThread): decided to cancel (in which case do not proceed with obtaining the Onion address and starting the web server). """ - def __init__(self, function, kwargs=None): + def __init__(self, common, function, kwargs=None): super(OnionThread, self).__init__() - common.log('OnionThread', '__init__') + + self.common = common + + self.common.log('OnionThread', '__init__') self.function = function if not kwargs: self.kwargs = {} @@ -827,6 +830,6 @@ class OnionThread(QtCore.QThread): self.kwargs = kwargs def run(self): - common.log('OnionThread', 'run') + self.common.log('OnionThread', 'run') self.function(**self.kwargs) diff --git a/onionshare_gui/server_status.py b/onionshare_gui/server_status.py index 03540415..ed8bc5f5 100644 --- a/onionshare_gui/server_status.py +++ b/onionshare_gui/server_status.py @@ -21,7 +21,7 @@ import platform from .alert import Alert from PyQt5 import QtCore, QtWidgets, QtGui -from onionshare import strings, common, settings +from onionshare import strings class ServerStatus(QtWidgets.QWidget): """ @@ -38,8 +38,11 @@ class ServerStatus(QtWidgets.QWidget): STATUS_WORKING = 1 STATUS_STARTED = 2 - def __init__(self, qtapp, app, web, file_selection, settings): + def __init__(self, common, qtapp, app, web, file_selection): super(ServerStatus, self).__init__() + + self.common = common + self.status = self.STATUS_STOPPED self.qtapp = qtapp @@ -47,8 +50,6 @@ class ServerStatus(QtWidgets.QWidget): self.web = web self.file_selection = file_selection - self.settings = settings - # Shutdown timeout layout self.shutdown_timeout_label = QtWidgets.QLabel(strings._('gui_settings_shutdown_timeout', True)) self.shutdown_timeout = QtWidgets.QDateTimeEdit() @@ -129,16 +130,16 @@ class ServerStatus(QtWidgets.QWidget): if self.status == self.STATUS_STARTED: self.url_description.show() - info_image = common.get_resource_path('images/info.png') + info_image = self.common.get_resource_path('images/info.png') self.url_description.setText(strings._('gui_url_description', True).format(info_image)) # Show a Tool Tip explaining the lifecycle of this URL - if self.settings.get('save_private_key'): - if self.settings.get('close_after_first_download'): + if self.common.settings.get('save_private_key'): + if self.common.settings.get('close_after_first_download'): self.url_description.setToolTip(strings._('gui_url_label_onetime_and_persistent', True)) else: self.url_description.setToolTip(strings._('gui_url_label_persistent', True)) else: - if self.settings.get('close_after_first_download'): + if self.common.settings.get('close_after_first_download'): self.url_description.setToolTip(strings._('gui_url_label_onetime', True)) else: self.url_description.setToolTip(strings._('gui_url_label_stay_open', True)) @@ -148,12 +149,12 @@ class ServerStatus(QtWidgets.QWidget): self.copy_url_button.show() - if self.settings.get('save_private_key'): - if not self.settings.get('slug'): - self.settings.set('slug', self.web.slug) - self.settings.save() + if self.common.settings.get('save_private_key'): + if not self.common.settings.get('slug'): + self.common.settings.set('slug', self.web.slug) + self.common.settings.save() - if self.settings.get('shutdown_timeout'): + if self.common.settings.get('shutdown_timeout'): self.shutdown_timeout_container.hide() if self.app.stealth: @@ -180,26 +181,26 @@ class ServerStatus(QtWidgets.QWidget): self.server_button.setEnabled(True) self.server_button.setText(strings._('gui_start_server', True)) self.server_button.setToolTip('') - if self.settings.get('shutdown_timeout'): + if self.common.settings.get('shutdown_timeout'): self.shutdown_timeout_container.show() elif self.status == self.STATUS_STARTED: self.server_button.setStyleSheet(button_started_style) self.server_button.setEnabled(True) self.server_button.setText(strings._('gui_stop_server', True)) - if self.settings.get('shutdown_timeout'): + if self.common.settings.get('shutdown_timeout'): self.shutdown_timeout_container.hide() self.server_button.setToolTip(strings._('gui_stop_server_shutdown_timeout_tooltip', True).format(self.timeout)) elif self.status == self.STATUS_WORKING: self.server_button.setStyleSheet(button_working_style) self.server_button.setEnabled(True) self.server_button.setText(strings._('gui_please_wait')) - if self.settings.get('shutdown_timeout'): + if self.common.settings.get('shutdown_timeout'): self.shutdown_timeout_container.hide() else: self.server_button.setStyleSheet(button_working_style) self.server_button.setEnabled(False) self.server_button.setText(strings._('gui_please_wait')) - if self.settings.get('shutdown_timeout'): + if self.common.settings.get('shutdown_timeout'): self.shutdown_timeout_container.hide() def server_button_clicked(self): @@ -207,12 +208,12 @@ class ServerStatus(QtWidgets.QWidget): Toggle starting or stopping the server. """ if self.status == self.STATUS_STOPPED: - if self.settings.get('shutdown_timeout'): + if self.common.settings.get('shutdown_timeout'): # Get the timeout chosen, stripped of its seconds. This prevents confusion if the share stops at (say) 37 seconds past the minute chosen self.timeout = self.shutdown_timeout.dateTime().toPyDateTime().replace(second=0, microsecond=0) # If the timeout has actually passed already before the user hit Start, refuse to start the server. if QtCore.QDateTime.currentDateTime().toPyDateTime() > self.timeout: - Alert(strings._('gui_server_timeout_expired', QtWidgets.QMessageBox.Warning)) + Alert(self.common, strings._('gui_server_timeout_expired', QtWidgets.QMessageBox.Warning)) else: self.start_server() else: @@ -252,7 +253,7 @@ class ServerStatus(QtWidgets.QWidget): """ Cancel the server. """ - common.log('ServerStatus', 'cancel_server', 'Canceling the server mid-startup') + self.common.log('ServerStatus', 'cancel_server', 'Canceling the server mid-startup') self.status = self.STATUS_WORKING self.shutdown_timeout_reset() self.update() diff --git a/onionshare_gui/settings_dialog.py b/onionshare_gui/settings_dialog.py index c70f5695..94aa8342 100644 --- a/onionshare_gui/settings_dialog.py +++ b/onionshare_gui/settings_dialog.py @@ -34,9 +34,12 @@ class SettingsDialog(QtWidgets.QDialog): """ settings_saved = QtCore.pyqtSignal() - def __init__(self, onion, qtapp, config=False, local_only=False): + def __init__(self, common, onion, qtapp, config=False, local_only=False): super(SettingsDialog, self).__init__() - common.log('SettingsDialog', '__init__') + + self.common = common + + self.common.log('SettingsDialog', '__init__') self.onion = onion self.qtapp = qtapp @@ -45,7 +48,7 @@ class SettingsDialog(QtWidgets.QDialog): self.setModal(True) self.setWindowTitle(strings._('gui_settings_window_title', True)) - self.setWindowIcon(QtGui.QIcon(common.get_resource_path('images/logo.png'))) + self.setWindowIcon(QtGui.QIcon(self.common.get_resource_path('images/logo.png'))) self.system = platform.system() @@ -157,7 +160,7 @@ class SettingsDialog(QtWidgets.QDialog): # obfs4 option radio # if the obfs4proxy binary is missing, we can't use obfs4 transports - (self.tor_path, self.tor_geo_ip_file_path, self.tor_geo_ipv6_file_path, self.obfs4proxy_file_path) = common.get_tor_paths() + (self.tor_path, self.tor_geo_ip_file_path, self.tor_geo_ipv6_file_path, self.obfs4proxy_file_path) = self.common.get_tor_paths() if not os.path.isfile(self.obfs4proxy_file_path): self.tor_bridges_use_obfs4_radio = QtWidgets.QRadioButton(strings._('gui_settings_tor_bridges_obfs4_radio_option_no_obfs4proxy', True)) self.tor_bridges_use_obfs4_radio.setEnabled(False) @@ -167,7 +170,7 @@ class SettingsDialog(QtWidgets.QDialog): # meek_lite-amazon option radio # if the obfs4proxy binary is missing, we can't use meek_lite-amazon transports - (self.tor_path, self.tor_geo_ip_file_path, self.tor_geo_ipv6_file_path, self.obfs4proxy_file_path) = common.get_tor_paths() + (self.tor_path, self.tor_geo_ip_file_path, self.tor_geo_ipv6_file_path, self.obfs4proxy_file_path) = self.common.get_tor_paths() if not os.path.isfile(self.obfs4proxy_file_path): self.tor_bridges_use_meek_lite_amazon_radio = QtWidgets.QRadioButton(strings._('gui_settings_tor_bridges_meek_lite_amazon_radio_option_no_obfs4proxy', True)) self.tor_bridges_use_meek_lite_amazon_radio.setEnabled(False) @@ -177,7 +180,7 @@ class SettingsDialog(QtWidgets.QDialog): # meek_lite-azure option radio # if the obfs4proxy binary is missing, we can't use meek_lite-azure transports - (self.tor_path, self.tor_geo_ip_file_path, self.tor_geo_ipv6_file_path, self.obfs4proxy_file_path) = common.get_tor_paths() + (self.tor_path, self.tor_geo_ip_file_path, self.tor_geo_ipv6_file_path, self.obfs4proxy_file_path) = self.common.get_tor_paths() if not os.path.isfile(self.obfs4proxy_file_path): self.tor_bridges_use_meek_lite_azure_radio = QtWidgets.QRadioButton(strings._('gui_settings_tor_bridges_meek_lite_azure_radio_option_no_obfs4proxy', True)) self.tor_bridges_use_meek_lite_azure_radio.setEnabled(False) @@ -333,7 +336,7 @@ class SettingsDialog(QtWidgets.QDialog): self.save_button.clicked.connect(self.save_clicked) self.cancel_button = QtWidgets.QPushButton(strings._('gui_settings_button_cancel', True)) self.cancel_button.clicked.connect(self.cancel_clicked) - version_label = QtWidgets.QLabel('OnionShare {0:s}'.format(common.get_version())) + version_label = QtWidgets.QLabel('OnionShare {0:s}'.format(self.common.version)) version_label.setStyleSheet('color: #666666') self.help_button = QtWidgets.QPushButton(strings._('gui_settings_button_help', True)) self.help_button.clicked.connect(self.help_clicked) @@ -374,7 +377,7 @@ class SettingsDialog(QtWidgets.QDialog): self.cancel_button.setFocus() # Load settings, and fill them in - self.old_settings = Settings(self.config) + self.old_settings = Settings(self.common, self.config) self.old_settings.load() close_after_first_download = self.old_settings.get('close_after_first_download') @@ -472,7 +475,7 @@ class SettingsDialog(QtWidgets.QDialog): """ Connection type bundled was toggled. If checked, hide authentication fields. """ - common.log('SettingsDialog', 'connection_type_bundled_toggled') + self.common.log('SettingsDialog', 'connection_type_bundled_toggled') if checked: self.authenticate_group.hide() self.connection_type_socks.hide() @@ -523,7 +526,7 @@ class SettingsDialog(QtWidgets.QDialog): """ Connection type automatic was toggled. If checked, hide authentication fields. """ - common.log('SettingsDialog', 'connection_type_automatic_toggled') + self.common.log('SettingsDialog', 'connection_type_automatic_toggled') if checked: self.authenticate_group.hide() self.connection_type_socks.hide() @@ -534,7 +537,7 @@ class SettingsDialog(QtWidgets.QDialog): Connection type control port was toggled. If checked, show extra fields for Tor control address and port. If unchecked, hide those extra fields. """ - common.log('SettingsDialog', 'connection_type_control_port_toggled') + self.common.log('SettingsDialog', 'connection_type_control_port_toggled') if checked: self.authenticate_group.show() self.connection_type_control_port_extras.show() @@ -549,7 +552,7 @@ class SettingsDialog(QtWidgets.QDialog): Connection type socket file was toggled. If checked, show extra fields for socket file. If unchecked, hide those extra fields. """ - common.log('SettingsDialog', 'connection_type_socket_file_toggled') + self.common.log('SettingsDialog', 'connection_type_socket_file_toggled') if checked: self.authenticate_group.show() self.connection_type_socket_file_extras.show() @@ -562,14 +565,14 @@ class SettingsDialog(QtWidgets.QDialog): """ Authentication option no authentication was toggled. """ - common.log('SettingsDialog', 'authenticate_no_auth_toggled') + self.common.log('SettingsDialog', 'authenticate_no_auth_toggled') def authenticate_password_toggled(self, checked): """ Authentication option password was toggled. If checked, show extra fields for password auth. If unchecked, hide those extra fields. """ - common.log('SettingsDialog', 'authenticate_password_toggled') + self.common.log('SettingsDialog', 'authenticate_password_toggled') if checked: self.authenticate_password_extras.show() else: @@ -580,7 +583,7 @@ class SettingsDialog(QtWidgets.QDialog): Toggle the 'Copy HidServAuth' button to copy the saved HidServAuth to clipboard. """ - common.log('SettingsDialog', 'hidservauth_copy_button_clicked', 'HidServAuth was copied to clipboard') + self.common.log('SettingsDialog', 'hidservauth_copy_button_clicked', 'HidServAuth was copied to clipboard') clipboard = self.qtapp.clipboard() clipboard.setText(self.old_settings.get('hidservauth_string')) @@ -589,7 +592,7 @@ class SettingsDialog(QtWidgets.QDialog): Test Tor Settings button clicked. With the given settings, see if we can successfully connect and authenticate to Tor. """ - common.log('SettingsDialog', 'test_tor_clicked') + self.common.log('SettingsDialog', 'test_tor_clicked') settings = self.settings_from_fields() try: @@ -604,17 +607,17 @@ class SettingsDialog(QtWidgets.QDialog): else: tor_status_update_func = None - onion = Onion() - onion.connect(settings=settings, config=self.config, tor_status_update_func=tor_status_update_func) + onion = Onion(self.common) + onion.connect(custom_settings=settings, config=self.config, tor_status_update_func=tor_status_update_func) # If an exception hasn't been raised yet, the Tor settings work - Alert(strings._('settings_test_success', True).format(onion.tor_version, onion.supports_ephemeral, onion.supports_stealth)) + Alert(self.common, strings._('settings_test_success', True).format(onion.tor_version, onion.supports_ephemeral, onion.supports_stealth)) # Clean up onion.cleanup() except (TorErrorInvalidSetting, TorErrorAutomatic, TorErrorSocketPort, TorErrorSocketFile, TorErrorMissingPassword, TorErrorUnreadableCookieFile, TorErrorAuthError, TorErrorProtocolError, BundledTorNotSupported, BundledTorTimeout) as e: - Alert(e.args[0], QtWidgets.QMessageBox.Warning) + Alert(self.common, e.args[0], QtWidgets.QMessageBox.Warning) if settings.get('connection_type') == 'bundled': self.tor_status.hide() self._enable_buttons() @@ -623,14 +626,14 @@ class SettingsDialog(QtWidgets.QDialog): """ Check for Updates button clicked. Manually force an update check. """ - common.log('SettingsDialog', 'check_for_updates') + self.common.log('SettingsDialog', 'check_for_updates') # Disable buttons self._disable_buttons() self.qtapp.processEvents() def update_timestamp(): # Update the last checked label - settings = Settings(self.config) + settings = Settings(self.common, self.config) settings.load() autoupdate_timestamp = settings.get('autoupdate_timestamp') self._update_autoupdate_timestamp(autoupdate_timestamp) @@ -644,22 +647,22 @@ class SettingsDialog(QtWidgets.QDialog): # Check for updates def update_available(update_url, installed_version, latest_version): - Alert(strings._("update_available", True).format(update_url, installed_version, latest_version)) + Alert(self.common, strings._("update_available", True).format(update_url, installed_version, latest_version)) close_forced_update_thread() def update_not_available(): - Alert(strings._('update_not_available', True)) + Alert(self.common, strings._('update_not_available', True)) close_forced_update_thread() def update_error(): - Alert(strings._('update_error_check_error', True), QtWidgets.QMessageBox.Warning) + Alert(self.common, strings._('update_error_check_error', True), QtWidgets.QMessageBox.Warning) close_forced_update_thread() def update_invalid_version(): - Alert(strings._('update_error_invalid_latest_version', True).format(e.latest_version), QtWidgets.QMessageBox.Warning) + Alert(self.common, strings._('update_error_invalid_latest_version', True).format(e.latest_version), QtWidgets.QMessageBox.Warning) close_forced_update_thread() - forced_update_thread = UpdateThread(self.onion, self.config, force=True) + forced_update_thread = UpdateThread(self.common, self.onion, self.config, force=True) forced_update_thread.update_available.connect(update_available) forced_update_thread.update_not_available.connect(update_not_available) forced_update_thread.update_error.connect(update_error) @@ -670,7 +673,7 @@ class SettingsDialog(QtWidgets.QDialog): """ Save button clicked. Save current settings to disk. """ - common.log('SettingsDialog', 'save_clicked') + self.common.log('SettingsDialog', 'save_clicked') settings = self.settings_from_fields() if settings: @@ -681,7 +684,7 @@ class SettingsDialog(QtWidgets.QDialog): reboot_onion = False if not self.local_only: if self.onion.is_authenticated(): - common.log('SettingsDialog', 'save_clicked', 'Connected to Tor') + self.common.log('SettingsDialog', 'save_clicked', 'Connected to Tor') def changed(s1, s2, keys): """ Compare the Settings objects s1 and s2 and return true if any values @@ -703,20 +706,20 @@ class SettingsDialog(QtWidgets.QDialog): reboot_onion = True else: - common.log('SettingsDialog', 'save_clicked', 'Not connected to Tor') + self.common.log('SettingsDialog', 'save_clicked', 'Not connected to Tor') # Tor isn't connected, so try connecting reboot_onion = True # Do we need to reinitialize Tor? if reboot_onion: # Reinitialize the Onion object - common.log('SettingsDialog', 'save_clicked', 'rebooting the Onion') + self.common.log('SettingsDialog', 'save_clicked', 'rebooting the Onion') self.onion.cleanup() tor_con = TorConnectionDialog(self.qtapp, settings, self.onion) tor_con.start() - common.log('SettingsDialog', 'save_clicked', 'Onion done rebooting, connected to Tor: {}'.format(self.onion.connected_to_tor)) + self.common.log('SettingsDialog', 'save_clicked', 'Onion done rebooting, connected to Tor: {}'.format(self.onion.connected_to_tor)) if self.onion.is_authenticated() and not tor_con.wasCanceled(): self.settings_saved.emit() @@ -733,9 +736,9 @@ class SettingsDialog(QtWidgets.QDialog): """ Cancel button clicked. """ - common.log('SettingsDialog', 'cancel_clicked') + self.common.log('SettingsDialog', 'cancel_clicked') if not self.onion.is_authenticated(): - Alert(strings._('gui_tor_connection_canceled', True), QtWidgets.QMessageBox.Warning) + Alert(self.common, strings._('gui_tor_connection_canceled', True), QtWidgets.QMessageBox.Warning) sys.exit() else: self.close() @@ -744,7 +747,7 @@ class SettingsDialog(QtWidgets.QDialog): """ Help button clicked. """ - common.log('SettingsDialog', 'help_clicked') + self.common.log('SettingsDialog', 'help_clicked') help_site = 'https://github.com/micahflee/onionshare/wiki' QtGui.QDesktopServices.openUrl(QtCore.QUrl(help_site)) @@ -752,8 +755,8 @@ class SettingsDialog(QtWidgets.QDialog): """ Return a Settings object that's full of values from the settings dialog. """ - common.log('SettingsDialog', 'settings_from_fields') - settings = Settings(self.config) + self.common.log('SettingsDialog', 'settings_from_fields') + settings = Settings(self.common, self.config) settings.load() # To get the last update timestamp settings.set('close_after_first_download', self.close_after_first_download_checkbox.isChecked()) @@ -858,25 +861,25 @@ class SettingsDialog(QtWidgets.QDialog): new_bridges = ''.join(new_bridges) settings.set('tor_bridges_use_custom_bridges', new_bridges) else: - Alert(strings._('gui_settings_tor_bridges_invalid', True)) + Alert(self.common, strings._('gui_settings_tor_bridges_invalid', True)) settings.set('no_bridges', True) return False return settings def closeEvent(self, e): - common.log('SettingsDialog', 'closeEvent') + self.common.log('SettingsDialog', 'closeEvent') # On close, if Tor isn't connected, then quit OnionShare altogether if not self.local_only: if not self.onion.is_authenticated(): - common.log('SettingsDialog', 'closeEvent', 'Closing while not connected to Tor') + self.common.log('SettingsDialog', 'closeEvent', 'Closing while not connected to Tor') # Wait 1ms for the event loop to finish, then quit QtCore.QTimer.singleShot(1, self.qtapp.quit) def _update_autoupdate_timestamp(self, autoupdate_timestamp): - common.log('SettingsDialog', '_update_autoupdate_timestamp') + self.common.log('SettingsDialog', '_update_autoupdate_timestamp') if autoupdate_timestamp: dt = datetime.datetime.fromtimestamp(autoupdate_timestamp) @@ -893,7 +896,7 @@ class SettingsDialog(QtWidgets.QDialog): self._enable_buttons() def _disable_buttons(self): - common.log('SettingsDialog', '_disable_buttons') + self.common.log('SettingsDialog', '_disable_buttons') self.check_for_updates_button.setEnabled(False) self.connection_type_test_button.setEnabled(False) @@ -901,7 +904,7 @@ class SettingsDialog(QtWidgets.QDialog): self.cancel_button.setEnabled(False) def _enable_buttons(self): - common.log('SettingsDialog', '_enable_buttons') + self.common.log('SettingsDialog', '_enable_buttons') # We can't check for updates if we're still not connected to Tor if not self.onion.connected_to_tor: self.check_for_updates_button.setEnabled(False) diff --git a/onionshare_gui/tor_connection_dialog.py b/onionshare_gui/tor_connection_dialog.py index dc472725..2ee13a66 100644 --- a/onionshare_gui/tor_connection_dialog.py +++ b/onionshare_gui/tor_connection_dialog.py @@ -19,7 +19,7 @@ along with this program. If not, see . """ from PyQt5 import QtCore, QtWidgets, QtGui -from onionshare import strings, common +from onionshare import strings from onionshare.onion import * from .alert import Alert @@ -30,16 +30,23 @@ class TorConnectionDialog(QtWidgets.QProgressDialog): """ open_settings = QtCore.pyqtSignal() - def __init__(self, qtapp, settings, onion): + def __init__(self, common, qtapp, onion, custom_settings=False): super(TorConnectionDialog, self).__init__(None) - common.log('TorConnectionDialog', '__init__') + + self.common = common + + if custom_settings: + self.settings = custom_settings + else: + self.settings = self.common.settings + + self.common.log('TorConnectionDialog', '__init__') self.qtapp = qtapp - self.settings = settings self.onion = onion self.setWindowTitle("OnionShare") - self.setWindowIcon(QtGui.QIcon(common.get_resource_path('images/logo.png'))) + self.setWindowIcon(QtGui.QIcon(self.common.get_resource_path('images/logo.png'))) self.setModal(True) self.setFixedSize(400, 150) @@ -55,9 +62,9 @@ class TorConnectionDialog(QtWidgets.QProgressDialog): self._tor_status_update(0, '') def start(self): - common.log('TorConnectionDialog', 'start') + self.common.log('TorConnectionDialog', 'start') - t = TorConnectionThread(self, self.settings, self.onion) + t = TorConnectionThread(self.common, self.settings, self, self.onion) t.tor_status_update.connect(self._tor_status_update) t.connected_to_tor.connect(self._connected_to_tor) t.canceled_connecting_to_tor.connect(self._canceled_connecting_to_tor) @@ -77,14 +84,14 @@ class TorConnectionDialog(QtWidgets.QProgressDialog): self.setLabelText("{}
{}".format(strings._('connecting_to_tor', True), summary)) def _connected_to_tor(self): - common.log('TorConnectionDialog', '_connected_to_tor') + self.common.log('TorConnectionDialog', '_connected_to_tor') self.active = False # Close the dialog after connecting self.setValue(self.maximum()) def _canceled_connecting_to_tor(self): - common.log('TorConnectionDialog', '_canceled_connecting_to_tor') + self.common.log('TorConnectionDialog', '_canceled_connecting_to_tor') self.active = False self.onion.cleanup() @@ -92,12 +99,12 @@ class TorConnectionDialog(QtWidgets.QProgressDialog): QtCore.QTimer.singleShot(1, self.cancel) def _error_connecting_to_tor(self, msg): - common.log('TorConnectionDialog', '_error_connecting_to_tor') + self.common.log('TorConnectionDialog', '_error_connecting_to_tor') self.active = False def alert_and_open_settings(): # Display the exception in an alert box - Alert("{}\n\n{}".format(msg, strings._('gui_tor_connection_error_settings', True)), QtWidgets.QMessageBox.Warning) + Alert(self.common, "{}\n\n{}".format(msg, strings._('gui_tor_connection_error_settings', True)), QtWidgets.QMessageBox.Warning) # Open settings self.open_settings.emit() @@ -113,16 +120,20 @@ class TorConnectionThread(QtCore.QThread): canceled_connecting_to_tor = QtCore.pyqtSignal() error_connecting_to_tor = QtCore.pyqtSignal(str) - def __init__(self, dialog, settings, onion): + def __init__(self, common, settings, dialog, onion): super(TorConnectionThread, self).__init__() - common.log('TorConnectionThread', '__init__') + + self.common = common + + self.common.log('TorConnectionThread', '__init__') + + self.settings = settings self.dialog = dialog - self.settings = settings self.onion = onion def run(self): - common.log('TorConnectionThread', 'run') + self.common.log('TorConnectionThread', 'run') # Connect to the Onion try: @@ -133,11 +144,11 @@ class TorConnectionThread(QtCore.QThread): self.canceled_connecting_to_tor.emit() except BundledTorCanceled as e: - common.log('TorConnectionThread', 'run', 'caught exception: BundledTorCanceled') + self.common.log('TorConnectionThread', 'run', 'caught exception: BundledTorCanceled') self.canceled_connecting_to_tor.emit() except Exception as e: - common.log('TorConnectionThread', 'run', 'caught exception: {}'.format(e.args[0])) + self.common.log('TorConnectionThread', 'run', 'caught exception: {}'.format(e.args[0])) self.error_connecting_to_tor.emit(str(e.args[0])) def _tor_status_update(self, progress, summary): diff --git a/onionshare_gui/update_checker.py b/onionshare_gui/update_checker.py index 8b4884a2..5dc72091 100644 --- a/onionshare_gui/update_checker.py +++ b/onionshare_gui/update_checker.py @@ -25,7 +25,7 @@ from onionshare import socks from onionshare.settings import Settings from onionshare.onion import Onion -from . import strings, common +from . import strings class UpdateCheckerCheckError(Exception): """ @@ -55,16 +55,19 @@ class UpdateChecker(QtCore.QObject): update_error = QtCore.pyqtSignal() update_invalid_version = QtCore.pyqtSignal() - def __init__(self, onion, config=False): + def __init__(self, common, onion, config=False): super(UpdateChecker, self).__init__() - common.log('UpdateChecker', '__init__') + + self.common = common + + self.common.log('UpdateChecker', '__init__') self.onion = onion self.config = config def check(self, force=False, config=False): - common.log('UpdateChecker', 'check', 'force={}'.format(force)) + self.common.log('UpdateChecker', 'check', 'force={}'.format(force)) # Load the settings - settings = Settings(config) + settings = Settings(self.common, config) settings.load() # If force=True, then definitely check @@ -87,11 +90,11 @@ class UpdateChecker(QtCore.QObject): # Check for updates if check_for_updates: - common.log('UpdateChecker', 'check', 'checking for updates') + self.common.log('UpdateChecker', 'check', 'checking for updates') # Download the latest-version file over Tor try: # User agent string includes OnionShare version and platform - user_agent = 'OnionShare {}, {}'.format(common.get_version(), platform.system()) + user_agent = 'OnionShare {}, {}'.format(self.common.version, self.common.platform) # If the update is forced, add '?force=1' to the URL, to more # accurately measure daily users @@ -104,7 +107,7 @@ class UpdateChecker(QtCore.QObject): else: onion_domain = 'elx57ue5uyfplgva.onion' - common.log('UpdateChecker', 'check', 'loading http://{}{}'.format(onion_domain, path)) + self.common.log('UpdateChecker', 'check', 'loading http://{}{}'.format(onion_domain, path)) (socks_address, socks_port) = self.onion.get_tor_socks_port() socks.set_default_proxy(socks.SOCKS5, socks_address, socks_port) @@ -122,10 +125,10 @@ class UpdateChecker(QtCore.QObject): http_response = s.recv(1024) latest_version = http_response[http_response.find(b'\r\n\r\n'):].strip().decode('utf-8') - common.log('UpdateChecker', 'check', 'latest OnionShare version: {}'.format(latest_version)) + self.common.log('UpdateChecker', 'check', 'latest OnionShare version: {}'.format(latest_version)) except Exception as e: - common.log('UpdateChecker', 'check', '{}'.format(e)) + self.common.log('UpdateChecker', 'check', '{}'.format(e)) self.update_error.emit() raise UpdateCheckerCheckError @@ -145,7 +148,7 @@ class UpdateChecker(QtCore.QObject): # Do we need to update? update_url = 'https://github.com/micahflee/onionshare/releases/tag/v{}'.format(latest_version) - installed_version = common.get_version() + installed_version = self.common.version if installed_version < latest_version: self.update_available.emit(update_url, installed_version, latest_version) return @@ -159,17 +162,20 @@ class UpdateThread(QtCore.QThread): update_error = QtCore.pyqtSignal() update_invalid_version = QtCore.pyqtSignal() - def __init__(self, onion, config=False, force=False): + def __init__(self, common, onion, config=False, force=False): super(UpdateThread, self).__init__() - common.log('UpdateThread', '__init__') + + self.common = common + + self.common.log('UpdateThread', '__init__') self.onion = onion self.config = config self.force = force def run(self): - common.log('UpdateThread', 'run') + self.common.log('UpdateThread', 'run') - u = UpdateChecker(self.onion, self.config) + u = UpdateChecker(self.common, self.onion, self.config) u.update_available.connect(self._update_available) u.update_not_available.connect(self._update_not_available) u.update_error.connect(self._update_error) @@ -179,25 +185,25 @@ class UpdateThread(QtCore.QThread): u.check(config=self.config,force=self.force) except Exception as e: # If update check fails, silently ignore - common.log('UpdateThread', 'run', '{}'.format(e)) + self.common.log('UpdateThread', 'run', '{}'.format(e)) pass def _update_available(self, update_url, installed_version, latest_version): - common.log('UpdateThread', '_update_available') + self.common.log('UpdateThread', '_update_available') self.active = False self.update_available.emit(update_url, installed_version, latest_version) def _update_not_available(self): - common.log('UpdateThread', '_update_not_available') + self.common.log('UpdateThread', '_update_not_available') self.active = False self.update_not_available.emit() def _update_error(self): - common.log('UpdateThread', '_update_error') + self.common.log('UpdateThread', '_update_error') self.active = False self.update_error.emit() def _update_invalid_version(self): - common.log('UpdateThread', '_update_invalid_version') + self.common.log('UpdateThread', '_update_invalid_version') self.active = False self.update_invalid_version.emit() diff --git a/setup.py b/setup.py index 23e1ea17..99222ef0 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,8 @@ data_files=[ (os.path.join(sys.prefix, 'share/onionshare'), file_list('share')), (os.path.join(sys.prefix, 'share/onionshare/images'), file_list('share/images')), (os.path.join(sys.prefix, 'share/onionshare/locale'), file_list('share/locale')), - (os.path.join(sys.prefix, 'share/onionshare/html'), file_list('share/html')), + (os.path.join(sys.prefix, 'share/onionshare/templates'), file_list('share/templates')), + (os.path.join(sys.prefix, 'share/onionshare/static'), file_list('share/static')) ] if platform.system() != 'OpenBSD': data_files.append(('/usr/share/nautilus-python/extensions/', ['install/scripts/onionshare-nautilus.py'])) diff --git a/share/html/404.html b/share/html/404.html deleted file mode 100644 index 09d0fc3c..00000000 --- a/share/html/404.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - Error 404 - - - - 404 - diff --git a/share/html/denied.html b/share/html/denied.html deleted file mode 100644 index a82ac027..00000000 --- a/share/html/denied.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - OnionShare - - - - -

OnionShare download in progress

- - diff --git a/share/html/index.html b/share/html/index.html deleted file mode 100644 index 57711e02..00000000 --- a/share/html/index.html +++ /dev/null @@ -1,208 +0,0 @@ - - - - OnionShare - - - - - - - -
-
- -
- -

OnionShare

-
- - - - - - - - {% for info in file_info.dirs %} - - - - - - {% endfor %} - {% for info in file_info.files %} - - - - - - {% endfor %} -
FilenameSize
- - {{ info.basename }} - {{ info.size_human }}
- - {{ info.basename }} - {{ info.size_human }}
- - - diff --git a/share/locale/en.json b/share/locale/en.json index fb29465b..525dab04 100644 --- a/share/locale/en.json +++ b/share/locale/en.json @@ -7,9 +7,12 @@ "wait_for_hs_yup": "Ready!", "give_this_url": "Give this address to the person you're sending the file to:", "give_this_url_stealth": "Give this address and HidServAuth line to the person you're sending the file to:", + "give_this_url_receive": "Give this address to the people sending you files:", + "give_this_url_receive_stealth": "Give this address and HidServAuth line to the people sending you files:", "ctrlc_to_stop": "Press Ctrl+C to stop the server", "not_a_file": "{0:s} is not a valid file.", "not_a_readable_file": "{0:s} is not a readable file.", + "no_filenames": "You must specify a list of files to share.", "no_available_port": "Could not start the Onion service as there was no available port.", "download_page_loaded": "Download page loaded", "other_page_loaded": "Address loaded", @@ -30,6 +33,7 @@ "help_stay_open": "Keep onion service running after download has finished", "help_shutdown_timeout": "Shut down the onion service after N seconds", "help_stealth": "Create stealth onion service (advanced)", + "help_receive": "Receive files instead of sending them", "help_debug": "Log application errors to stdout, and log web errors to disk", "help_filename": "List of files or folders to share", "help_config": "Path to a custom JSON config file (optional)", @@ -60,7 +64,7 @@ "gui_download_progress_complete": "%p%, Time Elapsed: {0:s}", "gui_download_progress_starting": "{0:s}, %p% (Computing ETA)", "gui_download_progress_eta": "{0:s}, ETA: {1:s}, %p%", - "version_string": "Onionshare {0:s} | https://onionshare.org/", + "version_string": "OnionShare {0:s} | https://onionshare.org/", "gui_quit_title": "Transfer in Progress", "gui_quit_warning": "You're in the process of sending files. Are you sure you want to quit OnionShare?", "gui_quit_warning_quit": "Quit", @@ -154,5 +158,10 @@ "gui_file_info": "{} Files, {}", "gui_file_info_single": "{} File, {}", "info_in_progress_downloads_tooltip": "{} download(s) in progress", - "info_completed_downloads_tooltip": "{} download(s) completed" + "info_completed_downloads_tooltip": "{} download(s) completed", + "error_cannot_create_downloads_dir": "Error creating downloads folder: {}", + "error_downloads_dir_not_writable": "The downloads folder isn't writable: {}", + "receive_mode_downloads_dir": "Files people send you will appear in this folder: {}", + "receive_mode_warning": "Warning: Some files can hack your computer if you open them! Only open files from people you trust, or if you know what you're doing.", + "receive_mode_received_file": "Received file: {}" } diff --git a/share/static/css/style.css b/share/static/css/style.css new file mode 100644 index 00000000..29b839a7 --- /dev/null +++ b/share/static/css/style.css @@ -0,0 +1,144 @@ +.clearfix:after { + content: "."; + display: block; + clear: both; + visibility: hidden; + line-height: 0; + height: 0; +} + +body { + margin: 0; + font-family: Helvetica; +} + +header { + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + background: #fcfcfc; + background: -webkit-linear-gradient(top, #fcfcfc 0%, #f2f2f2 100%); + padding: 0.8rem; +} + +header .logo { + vertical-align: middle; + width: 45px; + height: 45px; +} + +header h1 { + display: inline-block; + margin: 0 0 0 0.5rem; + vertical-align: middle; + font-weight: normal; + font-size: 1.5rem; + color: #666666; +} + +header .right { + float: right; + font-size: .75rem; +} + +header .right ul li { + display: inline; + margin: 0 0 0 .5rem; + font-size: 1rem; +} + +.button { + color: #ffffff; + background-color: #4e064f; + padding: 10px; + border: 0; + border-radius: 5px; + text-decoration: none; + margin-left: 1rem; + cursor: pointer; +} + +.close-button { + color: #ffffff; + background-color: #c90c0c; + padding: 10px; + border: 0; + border-radius: 5px; + text-decoration: none; + margin-left: 1rem; + cursor: pointer; + position: absolute; + right: 10px; + bottom: 10px; +} + +table.file-list { + width: 100%; + margin: 0 auto; + border-collapse: collapse; +} + +table.file-list th { + text-align: left; + text-transform: uppercase; + font-weight: normal; + color: #666666; + padding: 0.5rem; +} + +table.file-list tr { + border-bottom: 1px solid #e0e0e0; +} + +table.file-list td { + white-space: nowrap; + padding: 0.5rem 10rem 0.5rem 0.8rem; +} + +table.file-list td img { + vertical-align: middle; + margin-right: 0.5rem; +} + +table.file-list td:last-child { + width: 100%; +} + +.upload-wrapper { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; +} + +.upload { + text-align: center; +} + +.upload img { + width: 120px; + height: 120px; +} + +.upload .upload-header { + font-size: 30px; + font-weight: normal; + color: #666666; + margin: 0 0 10px 0; +} + +.upload .upload-description { + color: #666666; + margin: 0 0 20px 0; +} + +ul.flashes { + list-style: none; + margin: 0; + padding: 0; + color: #cc0000; + text-align: left; +} + +ul.flashes li { + margin: 0; + padding: 10px; +} diff --git a/share/images/favicon.ico b/share/static/img/favicon.ico similarity index 100% rename from share/images/favicon.ico rename to share/static/img/favicon.ico diff --git a/share/static/img/logo.png b/share/static/img/logo.png new file mode 100644 index 00000000..43884c1f Binary files /dev/null and b/share/static/img/logo.png differ diff --git a/share/static/img/logo_large.png b/share/static/img/logo_large.png new file mode 100644 index 00000000..ee8f26ac Binary files /dev/null and b/share/static/img/logo_large.png differ diff --git a/share/images/web_file.png b/share/static/img/web_file.png similarity index 100% rename from share/images/web_file.png rename to share/static/img/web_file.png diff --git a/share/images/web_folder.png b/share/static/img/web_folder.png similarity index 100% rename from share/images/web_folder.png rename to share/static/img/web_folder.png diff --git a/share/static/js/send.js b/share/static/js/send.js new file mode 100644 index 00000000..43e9892d --- /dev/null +++ b/share/static/js/send.js @@ -0,0 +1,75 @@ +// Function to convert human-readable sizes back to bytes, for sorting +function unhumanize(text) { + var powers = {'b': 0, 'k': 1, 'm': 2, 'g': 3, 't': 4}; + var regex = /(\d+(?:\.\d+)?)\s?(B|K|M|G|T)?/i; + var res = regex.exec(text); + if(res[2] === undefined) { + // Account for alphabetical words (file/dir names) + return text; + } else { + return res[1] * Math.pow(1024, powers[res[2].toLowerCase()]); + } +} +function sortTable(n) { + var table, rows, switching, i, x, y, shouldSwitch, dir, switchcount = 0; + table = document.getElementById("file-list"); + switching = true; + // Set the sorting direction to ascending: + dir = "asc"; + /* Make a loop that will continue until + no switching has been done: */ + while (switching) { + // Start by saying: no switching is done: + switching = false; + rows = table.getElementsByTagName("TR"); + /* Loop through all table rows (except the + first, which contains table headers): */ + for (i = 1; i < (rows.length - 1); i++) { + // Start by saying there should be no switching: + shouldSwitch = false; + /* Get the two elements you want to compare, + one from current row and one from the next: */ + x = rows[i].getElementsByTagName("TD")[n]; + y = rows[i + 1].getElementsByTagName("TD")[n]; + /* Check if the two rows should switch place, + based on the direction, asc or desc: */ + if (dir == "asc") { + if (unhumanize(x.innerHTML.toLowerCase()) > unhumanize(y.innerHTML.toLowerCase())) { + // If so, mark as a switch and break the loop: + shouldSwitch= true; + break; + } + } else if (dir == "desc") { + if (unhumanize(x.innerHTML.toLowerCase()) < unhumanize(y.innerHTML.toLowerCase())) { + // If so, mark as a switch and break the loop: + shouldSwitch= true; + break; + } + } + } + if (shouldSwitch) { + /* If a switch has been marked, make the switch + and mark that a switch has been done: */ + rows[i].parentNode.insertBefore(rows[i + 1], rows[i]); + switching = true; + // Each time a switch is done, increase this count by 1: + switchcount ++; + } else { + /* If no switching has been done AND the direction is "asc", + set the direction to "desc" and run the while loop again. */ + if (switchcount == 0 && dir == "asc") { + dir = "desc"; + switching = true; + } + } + } +} + + +// Set click handlers +document.getElementById("filename-header").addEventListener("click", function(){ + sortTable(0); +}); +document.getElementById("size-header").addEventListener("click", function(){ + sortTable(1); +}); diff --git a/share/templates/404.html b/share/templates/404.html new file mode 100644 index 00000000..b704f9f2 --- /dev/null +++ b/share/templates/404.html @@ -0,0 +1,10 @@ + + + + OnionShare: Error 404 + + + +

Error 404: You probably typed the OnionShare address wrong

+ + diff --git a/share/templates/closed.html b/share/templates/closed.html new file mode 100644 index 00000000..167d0efc --- /dev/null +++ b/share/templates/closed.html @@ -0,0 +1,10 @@ + + + + OnionShare is closed + + + +

Thank you for using OnionShare

+ + diff --git a/share/templates/denied.html b/share/templates/denied.html new file mode 100644 index 00000000..5d411d62 --- /dev/null +++ b/share/templates/denied.html @@ -0,0 +1,10 @@ + + + + OnionShare + + + +

OnionShare download in progress

+ + diff --git a/share/templates/receive.html b/share/templates/receive.html new file mode 100644 index 00000000..d1ec3b3a --- /dev/null +++ b/share/templates/receive.html @@ -0,0 +1,43 @@ + + + + OnionShare + + + + + +
+ +

OnionShare

+
+ +
+
+

+

Send Files

+

Select the files you want to send, then click "Send Files"...

+
+

+

+
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} +
+
+
+
+ +
+ +
+ + + diff --git a/share/templates/send.html b/share/templates/send.html new file mode 100644 index 00000000..ba43f306 --- /dev/null +++ b/share/templates/send.html @@ -0,0 +1,52 @@ + + + + OnionShare + + + + + + + +
+
+ +
+ +

OnionShare

+
+ + + + + + + + {% for info in file_info.dirs %} + + + + + + {% endfor %} + {% for info in file_info.files %} + + + + + + {% endfor %} +
FilenameSize
+ + {{ info.basename }} + {{ info.size_human }}
+ + {{ info.basename }} + {{ info.size_human }}
+ + + diff --git a/test/conftest.py b/test/conftest.py index 8f10162b..e843bbbc 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -8,7 +8,7 @@ import tempfile import pytest -from onionshare import common +from onionshare import common, web @pytest.fixture def temp_dir_1024(): @@ -64,8 +64,9 @@ def temp_file_1024_delete(): # pytest > 2.9 only needs @pytest.fixture @pytest.yield_fixture(scope='session') def custom_zw(): - zw = common.ZipWriter( - zip_filename=common.random_string(4, 6), + zw = web.ZipWriter( + common.Common(), + zip_filename=common.Common.random_string(4, 6), processed_size_callback=lambda _: 'custom_callback' ) yield zw @@ -76,7 +77,7 @@ def custom_zw(): # pytest > 2.9 only needs @pytest.fixture @pytest.yield_fixture(scope='session') def default_zw(): - zw = common.ZipWriter() + zw = web.ZipWriter(common.Common()) yield zw zw.close() tmp_dir = os.path.dirname(zw.zip_filename) @@ -118,16 +119,6 @@ def platform_windows(monkeypatch): monkeypatch.setattr('platform.system', lambda: 'Windows') -@pytest.fixture -def set_debug_false(monkeypatch): - monkeypatch.setattr('onionshare.common.debug', False) - - -@pytest.fixture -def set_debug_true(monkeypatch): - monkeypatch.setattr('onionshare.common.debug', True) - - @pytest.fixture def sys_argv_sys_prefix(monkeypatch): monkeypatch.setattr('sys.argv', [sys.prefix]) @@ -157,3 +148,7 @@ def time_time_100(monkeypatch): @pytest.fixture def time_strftime(monkeypatch): monkeypatch.setattr('time.strftime', lambda _: 'Jun 06 2013 11:05:00') + +@pytest.fixture +def common_obj(): + return common.Common() diff --git a/test/test_onionshare.py b/test/test_onionshare.py index 76e471bd..398fd0d3 100644 --- a/test/test_onionshare.py +++ b/test/test_onionshare.py @@ -22,6 +22,7 @@ import os import pytest from onionshare import OnionShare +from onionshare.common import Common class MyOnion: @@ -37,7 +38,8 @@ class MyOnion: @pytest.fixture def onionshare_obj(): - return OnionShare(MyOnion()) + common = Common() + return OnionShare(common, MyOnion()) class TestOnionShare: diff --git a/test/test_onionshare_common.py b/test/test_onionshare_common.py index cb864313..c0f9ad66 100644 --- a/test/test_onionshare_common.py +++ b/test/test_onionshare_common.py @@ -29,17 +29,16 @@ import zipfile import pytest -from onionshare import common - -DEFAULT_ZW_FILENAME_REGEX = re.compile(r'^onionshare_[a-z2-7]{6}.zip$') LOG_MSG_REGEX = re.compile(r""" ^\[Jun\ 06\ 2013\ 11:05:00\] \ TestModule\.\.dummy_func \ at\ 0x[a-f0-9]+>(:\ TEST_MSG)?$""", re.VERBOSE) -RANDOM_STR_REGEX = re.compile(r'^[a-z2-7]+$') SLUG_REGEX = re.compile(r'^([a-z]+)(-[a-z]+)?-([a-z]+)(-[a-z]+)?$') +# TODO: Improve the Common tests to test it all as a single class + + class TestBuildSlug: @pytest.mark.parametrize('test_input,expected', ( # VALID, two lowercase words, separated by a hyphen @@ -79,17 +78,17 @@ class TestBuildSlug: assert bool(SLUG_REGEX.match(test_input)) == expected - def test_build_slug_unique(self, sys_onionshare_dev_mode): - assert common.build_slug() != common.build_slug() + def test_build_slug_unique(self, common_obj, sys_onionshare_dev_mode): + assert common_obj.build_slug() != common_obj.build_slug() class TestDirSize: - def test_temp_dir_size(self, temp_dir_1024_delete): + def test_temp_dir_size(self, common_obj, temp_dir_1024_delete): """ dir_size() should return the total size (in bytes) of all files in a particular directory. """ - assert common.dir_size(temp_dir_1024_delete) == 1024 + assert common_obj.dir_size(temp_dir_1024_delete) == 1024 class TestEstimatedTimeRemaining: @@ -103,16 +102,16 @@ class TestEstimatedTimeRemaining: ((971, 1009, 83), '1s') )) def test_estimated_time_remaining( - self, test_input, expected, time_time_100): - assert common.estimated_time_remaining(*test_input) == expected + self, common_obj, test_input, expected, time_time_100): + assert common_obj.estimated_time_remaining(*test_input) == expected @pytest.mark.parametrize('test_input', ( (10, 20, 100), # if `time_elapsed == 0` (0, 37, 99) # if `download_rate == 0` )) - def test_raises_zero_division_error(self, test_input, time_time_100): + def test_raises_zero_division_error(self, common_obj, test_input, time_time_100): with pytest.raises(ZeroDivisionError): - common.estimated_time_remaining(*test_input) + common_obj.estimated_time_remaining(*test_input) class TestFormatSeconds: @@ -131,16 +130,16 @@ class TestFormatSeconds: (129674, '1d12h1m14s'), (56404.12, '15h40m4s') )) - def test_format_seconds(self, test_input, expected): - assert common.format_seconds(test_input) == expected + def test_format_seconds(self, common_obj, test_input, expected): + assert common_obj.format_seconds(test_input) == expected # TODO: test negative numbers? @pytest.mark.parametrize('test_input', ( 'string', lambda: None, [], {}, set() )) - def test_invalid_input_types(self, test_input): + def test_invalid_input_types(self, common_obj, test_input): with pytest.raises(TypeError): - common.format_seconds(test_input) + common_obj.format_seconds(test_input) class TestGetAvailablePort: @@ -148,29 +147,29 @@ class TestGetAvailablePort: (random.randint(1024, 1500), random.randint(1800, 2048)) for _ in range(50) )) - def test_returns_an_open_port(self, port_min, port_max): + def test_returns_an_open_port(self, common_obj, port_min, port_max): """ get_available_port() should return an open port within the range """ - port = common.get_available_port(port_min, port_max) + port = common_obj.get_available_port(port_min, port_max) assert port_min <= port <= port_max with socket.socket() as tmpsock: tmpsock.bind(('127.0.0.1', port)) class TestGetPlatform: - def test_darwin(self, platform_darwin): - assert common.get_platform() == 'Darwin' + def test_darwin(self, platform_darwin, common_obj): + assert common_obj.platform == 'Darwin' - def test_linux(self, platform_linux): - assert common.get_platform() == 'Linux' + def test_linux(self, platform_linux, common_obj): + assert common_obj.platform == 'Linux' - def test_windows(self, platform_windows): - assert common.get_platform() == 'Windows' + def test_windows(self, platform_windows, common_obj): + assert common_obj.platform == 'Windows' # TODO: double-check these tests class TestGetResourcePath: - def test_onionshare_dev_mode(self, sys_onionshare_dev_mode): + def test_onionshare_dev_mode(self, common_obj, sys_onionshare_dev_mode): prefix = os.path.join( os.path.dirname( os.path.dirname( @@ -178,29 +177,29 @@ class TestGetResourcePath: inspect.getfile( inspect.currentframe())))), 'share') assert ( - common.get_resource_path(os.path.join(prefix, 'test_filename')) == + common_obj.get_resource_path(os.path.join(prefix, 'test_filename')) == os.path.join(prefix, 'test_filename')) - def test_linux(self, platform_linux, sys_argv_sys_prefix): + def test_linux(self, common_obj, platform_linux, sys_argv_sys_prefix): prefix = os.path.join(sys.prefix, 'share/onionshare') assert ( - common.get_resource_path(os.path.join(prefix, 'test_filename')) == + common_obj.get_resource_path(os.path.join(prefix, 'test_filename')) == os.path.join(prefix, 'test_filename')) - def test_frozen_darwin(self, platform_darwin, sys_frozen, sys_meipass): + def test_frozen_darwin(self, common_obj, platform_darwin, sys_frozen, sys_meipass): prefix = os.path.join(sys._MEIPASS, 'share') assert ( - common.get_resource_path(os.path.join(prefix, 'test_filename')) == + common_obj.get_resource_path(os.path.join(prefix, 'test_filename')) == os.path.join(prefix, 'test_filename')) class TestGetTorPaths: # @pytest.mark.skipif(sys.platform != 'Darwin', reason='requires MacOS') ? - def test_get_tor_paths_darwin(self, platform_darwin, sys_frozen, sys_meipass): + def test_get_tor_paths_darwin(self, platform_darwin, common_obj, sys_frozen, sys_meipass): base_path = os.path.dirname( os.path.dirname( os.path.dirname( - common.get_resource_path('')))) + common_obj.get_resource_path('')))) tor_path = os.path.join( base_path, 'Resources', 'Tor', 'tor') tor_geo_ip_file_path = os.path.join( @@ -209,20 +208,20 @@ class TestGetTorPaths: base_path, 'Resources', 'Tor', 'geoip6') obfs4proxy_file_path = os.path.join( base_path, 'Resources', 'Tor', 'obfs4proxy') - assert (common.get_tor_paths() == + assert (common_obj.get_tor_paths() == (tor_path, tor_geo_ip_file_path, tor_geo_ipv6_file_path, obfs4proxy_file_path)) # @pytest.mark.skipif(sys.platform != 'Linux', reason='requires Linux') ? - def test_get_tor_paths_linux(self, platform_linux): - assert (common.get_tor_paths() == + def test_get_tor_paths_linux(self, platform_linux, common_obj): + assert (common_obj.get_tor_paths() == ('/usr/bin/tor', '/usr/share/tor/geoip', '/usr/share/tor/geoip6', '/usr/bin/obfs4proxy')) # @pytest.mark.skipif(sys.platform != 'Windows', reason='requires Windows') ? - def test_get_tor_paths_windows(self, platform_windows, sys_frozen): + def test_get_tor_paths_windows(self, platform_windows, common_obj, sys_frozen): base_path = os.path.join( os.path.dirname( os.path.dirname( - common.get_resource_path(''))), 'tor') + common_obj.get_resource_path(''))), 'tor') tor_path = os.path.join( os.path.join(base_path, 'Tor'), 'tor.exe') obfs4proxy_file_path = os.path.join( @@ -233,18 +232,10 @@ class TestGetTorPaths: tor_geo_ipv6_file_path = os.path.join( os.path.join( os.path.join(base_path, 'Data'), 'Tor'), 'geoip6') - assert (common.get_tor_paths() == + assert (common_obj.get_tor_paths() == (tor_path, tor_geo_ip_file_path, tor_geo_ipv6_file_path, obfs4proxy_file_path)) -class TestGetVersion: - def test_get_version(self, sys_onionshare_dev_mode): - with open(common.get_resource_path('version.txt')) as f: - version = f.read().strip() - - assert version == common.get_version() - - class TestHumanReadableFilesize: @pytest.mark.parametrize('test_input,expected', ( (1024 ** 0, '1.0 B'), @@ -257,8 +248,8 @@ class TestHumanReadableFilesize: (1024 ** 7, '1.0 ZiB'), (1024 ** 8, '1.0 YiB') )) - def test_human_readable_filesize(self, test_input, expected): - assert common.human_readable_filesize(test_input) == expected + def test_human_readable_filesize(self, common_obj, test_input, expected): + assert common_obj.human_readable_filesize(test_input) == expected class TestLog: @@ -273,82 +264,18 @@ class TestLog: def test_log_msg_regex(self, test_input): assert bool(LOG_MSG_REGEX.match(test_input)) - def test_output(self, set_debug_true, time_strftime): + def test_output(self, common_obj, time_strftime): def dummy_func(): pass + common_obj.debug = True + # From: https://stackoverflow.com/questions/1218933 with io.StringIO() as buf, contextlib.redirect_stdout(buf): - common.log('TestModule', dummy_func) - common.log('TestModule', dummy_func, 'TEST_MSG') + common_obj.log('TestModule', dummy_func) + common_obj.log('TestModule', dummy_func, 'TEST_MSG') output = buf.getvalue() line_one, line_two, _ = output.split('\n') assert LOG_MSG_REGEX.match(line_one) assert LOG_MSG_REGEX.match(line_two) - - -class TestSetDebug: - def test_debug_true(self, set_debug_false): - common.set_debug(True) - assert common.debug is True - - def test_debug_false(self, set_debug_true): - common.set_debug(False) - assert common.debug is False - - -class TestZipWriterDefault: - @pytest.mark.parametrize('test_input', ( - 'onionshare_{}.zip'.format(''.join( - random.choice('abcdefghijklmnopqrstuvwxyz234567') for _ in range(6) - )) for _ in range(50) - )) - def test_default_zw_filename_regex(self, test_input): - assert bool(DEFAULT_ZW_FILENAME_REGEX.match(test_input)) - - def test_zw_filename(self, default_zw): - zw_filename = os.path.basename(default_zw.zip_filename) - assert bool(DEFAULT_ZW_FILENAME_REGEX.match(zw_filename)) - - def test_zipfile_filename_matches_zipwriter_filename(self, default_zw): - assert default_zw.z.filename == default_zw.zip_filename - - def test_zipfile_allow_zip64(self, default_zw): - assert default_zw.z._allowZip64 is True - - def test_zipfile_mode(self, default_zw): - assert default_zw.z.mode == 'w' - - def test_callback(self, default_zw): - assert default_zw.processed_size_callback(None) is None - - def test_add_file(self, default_zw, temp_file_1024_delete): - default_zw.add_file(temp_file_1024_delete) - zipfile_info = default_zw.z.getinfo( - os.path.basename(temp_file_1024_delete)) - - assert zipfile_info.compress_type == zipfile.ZIP_DEFLATED - assert zipfile_info.file_size == 1024 - - def test_add_directory(self, temp_dir_1024_delete, default_zw): - previous_size = default_zw._size # size before adding directory - default_zw.add_dir(temp_dir_1024_delete) - assert default_zw._size == previous_size + 1024 - - -class TestZipWriterCustom: - @pytest.mark.parametrize('test_input', ( - common.random_string( - random.randint(2, 50), - random.choice((None, random.randint(2, 50))) - ) for _ in range(50) - )) - def test_random_string_regex(self, test_input): - assert bool(RANDOM_STR_REGEX.match(test_input)) - - def test_custom_filename(self, custom_zw): - assert bool(RANDOM_STR_REGEX.match(custom_zw.zip_filename)) - - def test_custom_callback(self, custom_zw): - assert custom_zw.processed_size_callback(None) == 'custom_callback' diff --git a/test/test_onionshare_settings.py b/test/test_onionshare_settings.py index e50eee41..67fd7b38 100644 --- a/test/test_onionshare_settings.py +++ b/test/test_onionshare_settings.py @@ -26,19 +26,16 @@ import pytest from onionshare import common, settings, strings -@pytest.fixture -def custom_version(monkeypatch): - monkeypatch.setattr(common, 'get_version', lambda: 'DUMMY_VERSION_1.2.3') - - @pytest.fixture def os_path_expanduser(monkeypatch): monkeypatch.setattr('os.path.expanduser', lambda path: path) @pytest.fixture -def settings_obj(custom_version, sys_onionshare_dev_mode, platform_linux): - return settings.Settings() +def settings_obj(sys_onionshare_dev_mode, platform_linux): + _common = common.Common() + _common.version = 'DUMMY_VERSION_1.2.3' + return settings.Settings(_common) class TestSettings: @@ -67,7 +64,8 @@ class TestSettings: 'save_private_key': False, 'private_key': '', 'slug': '', - 'hidservauth_string': '' + 'hidservauth_string': '', + 'downloads_dir': os.path.expanduser('~/OnionShare') } def test_fill_in_defaults(self, settings_obj): @@ -153,30 +151,27 @@ class TestSettings: def test_filename_darwin( self, - custom_version, monkeypatch, os_path_expanduser, platform_darwin): - obj = settings.Settings() + obj = settings.Settings(common.Common()) assert (obj.filename == '~/Library/Application Support/OnionShare/onionshare.json') def test_filename_linux( self, - custom_version, monkeypatch, os_path_expanduser, platform_linux): - obj = settings.Settings() + obj = settings.Settings(common.Common()) assert obj.filename == '~/.config/onionshare/onionshare.json' def test_filename_windows( self, - custom_version, monkeypatch, platform_windows): monkeypatch.setenv('APPDATA', 'C:') - obj = settings.Settings() + obj = settings.Settings(common.Common()) assert obj.filename == 'C:\\OnionShare\\onionshare.json' def test_set_custom_bridge(self, settings_obj): diff --git a/test/test_onionshare_strings.py b/test/test_onionshare_strings.py index d9fa9896..db941a26 100644 --- a/test/test_onionshare_strings.py +++ b/test/test_onionshare_strings.py @@ -22,7 +22,7 @@ import types import pytest -from onionshare import common, strings +from onionshare import strings # # Stub get_resource_path so it finds the correct path while running tests @@ -44,28 +44,28 @@ def test_underscore_is_function(): class TestLoadStrings: def test_load_strings_defaults_to_english( - self, locale_en, sys_onionshare_dev_mode): + self, common_obj, locale_en, sys_onionshare_dev_mode): """ load_strings() loads English by default """ - strings.load_strings(common) + strings.load_strings(common_obj) assert strings._('wait_for_hs') == "Waiting for HS to be ready:" def test_load_strings_loads_other_languages( - self, locale_fr, sys_onionshare_dev_mode): + self, common_obj, locale_fr, sys_onionshare_dev_mode): """ load_strings() loads other languages in different locales """ - strings.load_strings(common, "fr") + strings.load_strings(common_obj, "fr") assert strings._('wait_for_hs') == "En attente du HS:" def test_load_partial_strings( - self, locale_ru, sys_onionshare_dev_mode): - strings.load_strings(common) + self, common_obj, locale_ru, sys_onionshare_dev_mode): + strings.load_strings(common_obj) assert strings._("give_this_url") == ( "Отправьте эту ссылку тому человеку, " "которому вы хотите передать файл:") assert strings._('wait_for_hs') == "Waiting for HS to be ready:" def test_load_invalid_locale( - self, locale_invalid, sys_onionshare_dev_mode): + self, common_obj, locale_invalid, sys_onionshare_dev_mode): """ load_strings() raises a KeyError for an invalid locale """ with pytest.raises(KeyError): - strings.load_strings(common, 'XX') + strings.load_strings(common_obj, 'XX') diff --git a/test/test_onionshare_web.py b/test/test_onionshare_web.py new file mode 100644 index 00000000..a80e7098 --- /dev/null +++ b/test/test_onionshare_web.py @@ -0,0 +1,90 @@ +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2017 Micah Lee + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import contextlib +import inspect +import io +import os +import random +import re +import socket +import sys +import zipfile + +import pytest + +from onionshare.common import Common + +DEFAULT_ZW_FILENAME_REGEX = re.compile(r'^onionshare_[a-z2-7]{6}.zip$') +RANDOM_STR_REGEX = re.compile(r'^[a-z2-7]+$') + +class TestZipWriterDefault: + @pytest.mark.parametrize('test_input', ( + 'onionshare_{}.zip'.format(''.join( + random.choice('abcdefghijklmnopqrstuvwxyz234567') for _ in range(6) + )) for _ in range(50) + )) + def test_default_zw_filename_regex(self, test_input): + assert bool(DEFAULT_ZW_FILENAME_REGEX.match(test_input)) + + def test_zw_filename(self, default_zw): + zw_filename = os.path.basename(default_zw.zip_filename) + assert bool(DEFAULT_ZW_FILENAME_REGEX.match(zw_filename)) + + def test_zipfile_filename_matches_zipwriter_filename(self, default_zw): + assert default_zw.z.filename == default_zw.zip_filename + + def test_zipfile_allow_zip64(self, default_zw): + assert default_zw.z._allowZip64 is True + + def test_zipfile_mode(self, default_zw): + assert default_zw.z.mode == 'w' + + def test_callback(self, default_zw): + assert default_zw.processed_size_callback(None) is None + + def test_add_file(self, default_zw, temp_file_1024_delete): + default_zw.add_file(temp_file_1024_delete) + zipfile_info = default_zw.z.getinfo( + os.path.basename(temp_file_1024_delete)) + + assert zipfile_info.compress_type == zipfile.ZIP_DEFLATED + assert zipfile_info.file_size == 1024 + + def test_add_directory(self, temp_dir_1024_delete, default_zw): + previous_size = default_zw._size # size before adding directory + default_zw.add_dir(temp_dir_1024_delete) + assert default_zw._size == previous_size + 1024 + + +class TestZipWriterCustom: + @pytest.mark.parametrize('test_input', ( + Common.random_string( + random.randint(2, 50), + random.choice((None, random.randint(2, 50))) + ) for _ in range(50) + )) + def test_random_string_regex(self, test_input): + assert bool(RANDOM_STR_REGEX.match(test_input)) + + def test_custom_filename(self, custom_zw): + assert bool(RANDOM_STR_REGEX.match(custom_zw.zip_filename)) + + def test_custom_callback(self, custom_zw): + assert custom_zw.processed_size_callback(None) == 'custom_callback'