From 2e2851dc13d73352530dd4495c7e05603b2e520d Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 10 Apr 2024 23:38:38 +0200 Subject: Adding upstream version 2.1.2~dev0+20240219. Signed-off-by: Daniel Baumann --- deluge/ui/web/server.py | 823 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 deluge/ui/web/server.py (limited to 'deluge/ui/web/server.py') diff --git a/deluge/ui/web/server.py b/deluge/ui/web/server.py new file mode 100644 index 0000000..5fbdd4e --- /dev/null +++ b/deluge/ui/web/server.py @@ -0,0 +1,823 @@ +# +# Copyright (C) 2009-2010 Damien Churchill +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import fnmatch +import json +import logging +import mimetypes +import os +import tempfile +from pathlib import Path + +from twisted.application import internet, service +from twisted.internet import defer, reactor +from twisted.web import http, resource, server, static +from twisted.web.resource import EncodingResourceWrapper + +from deluge import common, component, configmanager +from deluge.common import is_ipv6 +from deluge.crypto_utils import check_ssl_keys, get_context_factory +from deluge.i18n import set_language, setup_translation +from deluge.ui.tracker_icons import TrackerIcons +from deluge.ui.web.auth import Auth +from deluge.ui.web.common import Template +from deluge.ui.web.json_api import JSON, WebApi, WebUtils +from deluge.ui.web.pluginmanager import PluginManager + +log = logging.getLogger(__name__) + +CONFIG_DEFAULTS = { + # Misc Settings + 'enabled_plugins': [], + 'default_daemon': '', + # Auth Settings + 'pwd_salt': 'c26ab3bbd8b137f99cd83c2c1c0963bcc1a35cad', + 'pwd_sha1': '2ce1a410bcdcc53064129b6d950f2e9fee4edc1e', + 'session_timeout': 3600, + 'sessions': {}, + # UI Settings + 'sidebar_show_zero': False, + 'sidebar_multiple_filters': True, + 'show_session_speed': False, + 'show_sidebar': True, + 'theme': 'gray', + 'first_login': True, + 'language': '', + # Server Settings + 'base': '/', + 'interface': '0.0.0.0', + 'port': 8112, + 'https': False, + 'pkey': 'ssl/daemon.pkey', + 'cert': 'ssl/daemon.cert', +} + +UI_CONFIG_KEYS = ( + 'theme', + 'sidebar_show_zero', + 'sidebar_multiple_filters', + 'show_session_speed', + 'base', + 'first_login', +) + + +def rpath(*paths): + """Convert a relative path into an absolute path relative to the location + of this script. + """ + return common.resource_filename('deluge.ui.web', os.path.join(*paths)) + + +def absolute_base_url(base): + """Returns base as absolute URL for links""" + if not base: + base = '/' + + if not base.startswith('/'): + base = '/' + base + + if not base.endswith('/'): + base += '/' + + return base + + +class GetText(resource.Resource): + def render(self, request): + request.setHeader(b'content-type', b'text/javascript; encoding=utf-8') + template = Template(filename=rpath('js', 'gettext.js')) + return template.render() + + +class MockGetText(resource.Resource): + """GetText Mocking class + + This class will mock the file `gettext.js` in case it does not exists. + It will be used to define the `_` (underscore) function for translations, + and will return the string to translate, as is. + """ + + def render(self, request): + request.setHeader(b'content-type', b'text/javascript; encoding=utf-8') + return b'function _(string) { return string; }' + + +class Upload(resource.Resource): + """ + Twisted Web resource to handle file uploads + """ + + def render(self, request): + """ + Saves all uploaded files to the disk and returns a list of filenames, + each on a new line. + """ + + # Block all other HTTP methods. + if request.method != b'POST': + request.setResponseCode(http.NOT_ALLOWED) + request.finish() + return server.NOT_DONE_YET + + files = request.args.get(b'file', []) + filenames = [] + + if files: + tempdir = tempfile.mkdtemp(prefix='delugeweb-') + log.debug('uploading files to %s', tempdir) + + for upload in files: + fd, fn = tempfile.mkstemp('.torrent', dir=tempdir) + os.write(fd, upload) + os.close(fd) + filenames.append(fn) + + log.debug('uploaded %d file(s)', len(filenames)) + + request.setHeader(b'content-type', b'text/html') + request.setResponseCode(http.OK) + return json.dumps({'success': bool(filenames), 'files': filenames}).encode() + + +class Render(resource.Resource): + def __init__(self): + super().__init__() + # Make a list of all the template files to check requests against. + self.template_files = fnmatch.filter(os.listdir(rpath('render')), '*.html') + + def getChild(self, path, request): # NOQA: N802 + request.render_file = path + return EncodingResourceWrapper(self, [server.GzipEncoderFactory()]) + + def render(self, request): + log.debug('Render template file: %s', request.render_file) + if not hasattr(request, 'render_file'): + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return '' + + request.setHeader(b'content-type', b'text/html') + + tpl_file = request.render_file.decode() + if tpl_file in self.template_files: + request.setResponseCode(http.OK) + else: + request.setResponseCode(http.NOT_FOUND) + tpl_file = '404.html' + + template = Template(filename=rpath(os.path.join('render', tpl_file))) + return template.render() + + +class Tracker(resource.Resource): + def __init__(self): + super().__init__() + try: + self.tracker_icons = component.get('TrackerIcons') + except KeyError: + self.tracker_icons = TrackerIcons() + + def getChild(self, path, request): # NOQA: N802 + request.tracker_name = path + return self + + def on_got_icon(self, icon, request): + if icon: + request.setHeader( + b'cache-control', b'public, must-revalidate, max-age=86400' + ) + request.setHeader(b'content-type', icon.get_mimetype().encode()) + request.setResponseCode(http.OK) + request.write(icon.get_data()) + request.finish() + else: + request.setResponseCode(http.NOT_FOUND) + request.finish() + + def render(self, request): + d = self.tracker_icons.fetch(request.tracker_name.decode()) + d.addCallback(self.on_got_icon, request) + return server.NOT_DONE_YET + + +class Flag(resource.Resource): + def getChild(self, path, request): # NOQA: N802 + request.country = path + return self + + def render(self, request): + flag = request.country.decode().lower() + '.png' + path = ('ui', 'data', 'pixmaps', 'flags', flag) + filename = common.resource_filename('deluge', os.path.join(*path)) + if os.path.exists(filename): + request.setHeader( + b'cache-control', b'public, must-revalidate, max-age=86400' + ) + request.setHeader(b'content-type', b'image/png') + with open(filename, 'rb') as _file: + data = _file.read() + request.setResponseCode(http.OK) + return data + else: + request.setResponseCode(http.NOT_FOUND) + return '' + + +class LookupResource(resource.Resource, component.Component): + def __init__(self, name, *directories): + resource.Resource.__init__(self) + component.Component.__init__(self, name) + + self.__paths = {} + for directory in directories: + self.add_directory(directory) + + def add_directory(self, directory, path=''): + log.debug('Adding directory `%s` with path `%s`', directory, path) + paths = self.__paths.setdefault(path, []) + paths.append(directory) + + def remove_directory(self, directory, path=''): + log.debug('Removing directory `%s`', directory) + self.__paths[path].remove(directory) + + def getChild(self, path, request): # NOQA: N802 + if hasattr(request, 'lookup_path'): + request.lookup_path = os.path.join(request.lookup_path, path) + else: + request.lookup_path = path + + if request.uri.endswith(b'css'): + return EncodingResourceWrapper(self, [server.GzipEncoderFactory()]) + else: + return self + + def render(self, request): + log.debug('Requested path: %s', request.lookup_path) + path = os.path.dirname(request.lookup_path).decode() + + if path in self.__paths: + filename = os.path.basename(request.path).decode() + for directory in self.__paths[path]: + path = os.path.join(directory, filename) + if os.path.isfile(path): + log.debug('Serving path: %s', path) + mime_type = mimetypes.guess_type(path) + request.setHeader(b'content-type', mime_type[0].encode()) + with open(path, 'rb') as _file: + data = _file.read() + return data + + request.setResponseCode(http.NOT_FOUND) + request.setHeader(b'content-type', b'text/html') + template = Template(filename=rpath(os.path.join('render', '404.html'))) + return template.render() + + +class ScriptResource(resource.Resource, component.Component): + def __init__(self): + resource.Resource.__init__(self) + component.Component.__init__(self, 'Scripts') + self.__scripts = {} + for script_type in ['normal', 'debug', 'dev']: + self.__scripts[script_type] = { + 'scripts': {}, + 'order': [], + 'files_exist': True, + } + + def has_script_type_files(self, script_type): + """Returns whether all the script files exist for this script type. + + Args: + script_type (str): The script type to check (normal, debug, dev). + + Returns: + bool: True if the files for this script type exist, otherwise False. + + """ + return self.__scripts[script_type]['files_exist'] + + def add_script(self, path, filepath, script_type=None): + """ + Adds a script or scripts to the script resource. + + :param path: The path of the script (this supports globbing) + :type path: string + :param filepath: The physical location of the script + :type filepath: string + :param script_type: The type of script to add (normal, debug, dev) + :param script_type: string + """ + if script_type not in ('dev', 'debug', 'normal'): + script_type = 'normal' + + self.__scripts[script_type]['scripts'][path] = filepath + self.__scripts[script_type]['order'].append(path) + if not os.path.isfile(filepath): + self.__scripts[script_type]['files_exist'] = False + + def add_script_folder(self, path, filepath, script_type=None, recurse=True): + """ + Adds a folder of scripts to the script resource. + + :param path: The path of the folder + :type path: string + :param filepath: The physical location of the script + :type filepath: string + :param script_type: The type of script to add (normal, debug, dev) + :param script_type: string + :param recurse: Whether or not to recurse into other folders + :param recurse: bool + """ + if script_type not in ('dev', 'debug', 'normal'): + script_type = 'normal' + + self.__scripts[script_type]['scripts'][path] = (filepath, recurse) + self.__scripts[script_type]['order'].append(path) + if not os.path.isdir(filepath): + self.__scripts[script_type]['files_exist'] = False + + def remove_script(self, path, script_type=None): + """ + Removes a script or folder of scripts from the script resource. + + :param path: The path of the folder + :type path: string + :param script_type: The type of script to add (normal, debug, dev) + :param script_type: string + """ + if script_type not in ('dev', 'debug', 'normal'): + script_type = 'normal' + + del self.__scripts[script_type]['scripts'][path] + self.__scripts[script_type]['order'].remove(path) + + def get_scripts(self, script_type=None): + """ + Returns a list of the scripts that can be used for producing + script tags. + + :param script_type: The type of scripts to get (normal, debug, dev) + :param script_type: string + """ + if script_type not in ('dev', 'debug', 'normal'): + script_type = 'normal' + + _scripts = self.__scripts[script_type]['scripts'] + _order = self.__scripts[script_type]['order'] + + scripts = [] + for path in _order: + # Index for grouping the scripts when inserting. + script_idx = len(scripts) + # A folder resource is enclosed in a tuple. + if isinstance(_scripts[path], tuple): + filepath, recurse = _scripts[path] + for root, dirnames, filenames in os.walk(filepath): + dirnames.sort(reverse=True) + files = sorted(fnmatch.filter(filenames, '*.js')) + + order_file = os.path.join(root, '.order') + if os.path.isfile(order_file): + with open(order_file) as _file: + for line in _file: + if line.startswith('+ '): + order_filename = line.split()[1] + files.pop(files.index(order_filename)) + files.insert(0, order_filename) + + # Ensure sub-directory scripts are top of list with root directory scripts bottom. + if dirnames: + scripts.extend( + ['js/' + os.path.basename(root) + '/' + f for f in files] + ) + else: + dirpath = ( + os.path.basename(os.path.dirname(root)) + + '/' + + os.path.basename(root) + ) + for filename in reversed(files): + scripts.insert(script_idx, 'js/' + dirpath + '/' + filename) + + if not recurse: + break + else: + scripts.append('js/' + path) + return scripts + + def getChild(self, path, request): # NOQA: N802 + if hasattr(request, 'lookup_path'): + request.lookup_path += b'/' + path + else: + request.lookup_path = path + return EncodingResourceWrapper(self, [server.GzipEncoderFactory()]) + + def render(self, request): + log.debug('Requested path: %s', request.lookup_path) + lookup_path = request.lookup_path.decode() + for script_type in ('dev', 'debug', 'normal'): + scripts = self.__scripts[script_type]['scripts'] + for pattern in scripts: + if not lookup_path.startswith(pattern): + continue + + filepath = scripts[pattern] + if isinstance(filepath, tuple): + filepath = filepath[0] + + path = filepath + lookup_path[len(pattern) :] + + if not os.path.isfile(path): + continue + + log.debug('Serving path: %s', path) + mime_type = mimetypes.guess_type(path) + request.setHeader(b'content-type', mime_type[0].encode()) + with open(path, 'rb') as _file: + data = _file.read() + return data + + request.setResponseCode(http.NOT_FOUND) + request.setHeader(b'content-type', b'text/html') + template = Template(filename=rpath(os.path.join('render', '404.html'))) + return template.render() + + +class Themes(static.File): + def getChild(self, path, request): # NOQA: N802 + child = static.File.getChild(self, path, request) + if request.uri.endswith(b'css'): + return EncodingResourceWrapper(child, [server.GzipEncoderFactory()]) + else: + return child + + +class TopLevel(resource.Resource): + __stylesheets = [ + 'css/ext-all-notheme.css', + 'css/ext-extensions.css', + 'css/deluge.css', + ] + + def __init__(self): + super().__init__() + + self.putChild(b'css', LookupResource('Css', rpath('css'))) + if os.path.isfile(rpath('js', 'gettext.js')): + self.putChild( + b'gettext.js', + EncodingResourceWrapper(GetText(), [server.GzipEncoderFactory()]), + ) + else: + log.warning( + 'Cannot find "gettext.js" translation file!' + ' Text will only be available in English.' + ) + self.putChild(b'gettext.js', MockGetText()) + self.putChild(b'flag', Flag()) + self.putChild(b'icons', LookupResource('Icons', rpath('icons'))) + self.putChild(b'images', LookupResource('Images', rpath('images'))) + self.putChild( + b'ui_images', + LookupResource( + 'UI_Images', + common.resource_filename('deluge.ui', os.path.join('data', 'pixmaps')), + ), + ) + + js = ScriptResource() + + # configure the dev scripts + js.add_script( + 'ext-base-debug.js', rpath('js', 'extjs', 'ext-base-debug.js'), 'dev' + ) + js.add_script( + 'ext-all-debug.js', rpath('js', 'extjs', 'ext-all-debug.js'), 'dev' + ) + js.add_script_folder( + 'ext-extensions', rpath('js', 'extjs', 'ext-extensions'), 'dev' + ) + js.add_script_folder('deluge-all', rpath('js', 'deluge-all'), 'dev') + + # configure the debug scripts + js.add_script( + 'ext-base-debug.js', rpath('js', 'extjs', 'ext-base-debug.js'), 'debug' + ) + js.add_script( + 'ext-all-debug.js', rpath('js', 'extjs', 'ext-all-debug.js'), 'debug' + ) + js.add_script( + 'ext-extensions-debug.js', + rpath('js', 'extjs', 'ext-extensions-debug.js'), + 'debug', + ) + js.add_script( + 'deluge-all-debug.js', rpath('js', 'deluge-all-debug.js'), 'debug' + ) + + # configure the normal scripts + js.add_script('ext-base.js', rpath('js', 'extjs', 'ext-base.js')) + js.add_script('ext-all.js', rpath('js', 'extjs', 'ext-all.js')) + js.add_script('ext-extensions.js', rpath('js', 'extjs', 'ext-extensions.js')) + js.add_script('deluge-all.js', rpath('js', 'deluge-all.js')) + + self.js = js + self.putChild(b'js', js) + self.putChild( + b'json', EncodingResourceWrapper(JSON(), [server.GzipEncoderFactory()]) + ) + self.putChild( + b'upload', EncodingResourceWrapper(Upload(), [server.GzipEncoderFactory()]) + ) + self.putChild(b'render', Render()) + self.putChild(b'themes', Themes(rpath('themes'))) + self.putChild(b'tracker', Tracker()) + + @property + def stylesheets(self): + return self.__stylesheets + + def get_themes(self): + themes_dir = Path(rpath('themes', 'css')) + themes = [ + theme.stem.split('xtheme-')[1] for theme in themes_dir.glob('xtheme-*.css') + ] + themes = [(theme, _(theme.capitalize())) for theme in themes] + return themes + + def set_theme(self, theme: str): + if not os.path.isfile(rpath('themes', 'css', f'xtheme-{theme}.css')): + theme = CONFIG_DEFAULTS.get('theme') + self.__theme = f'themes/css/xtheme-{theme}.css' + + # Only one xtheme CSS, ordered last to override other styles. + if 'xtheme-' in self.stylesheets[-1]: + self.__stylesheets.pop() + self.__stylesheets.append(self.__theme) + + def add_script(self, script): + """ + Adds a script to the server so it is included in the element + of the index page. + + :param script: The path to the script + :type script: string + """ + + self.__scripts.append(script) + self.__debug_scripts.append(script) + + def remove_script(self, script): + """ + Removes a script from the server. + + :param script: The path to the script + :type script: string + """ + self.__scripts.remove(script) + self.__debug_scripts.remove(script) + + def getChildWithDefault(self, path, request): # NOQA: N802 + # Calculate the request base + header = request.getHeader('x-deluge-base') + config_base = component.get('DelugeWeb').base + base = header if header else config_base + + first_request = not hasattr(request, 'base') + request.base = absolute_base_url(base).encode() + + base_resource = first_request and path.decode() == config_base.strip('/') + + if not path or base_resource: + return self + + return super().getChildWithDefault(path, request) + + def render(self, request): + uri_true = ('true', 'yes', 'on', '1') + uri_false = ('false', 'no', 'off', '0') + + debug_arg = None + req_dbg_arg = request.args.get(b'debug', [b''])[-1].decode().lower() + if req_dbg_arg in uri_true: + debug_arg = True + elif req_dbg_arg in uri_false: + debug_arg = False + + dev_arg = request.args.get(b'dev', [b''])[-1].decode().lower() in uri_true + dev_ver = 'dev' in common.get_version() + + script_type = 'normal' + if debug_arg is not None: + # Use debug arg to force switching to normal script type. + script_type = 'debug' if debug_arg else 'normal' + elif dev_arg or dev_ver: + # Also use dev files if development version. + script_type = 'dev' + + if not self.js.has_script_type_files(script_type): + if not dev_ver: + log.warning( + 'Failed to enable WebUI "%s" mode, script files are missing!', + script_type, + ) + # Fallback to checking other types in order and selecting first with + # files available. Ordered to start with dev files lookup. + for alt_script_type in [ + x for x in ['dev', 'debug', 'normal'] if x != script_type + ]: + if self.js.has_script_type_files(alt_script_type): + script_type = alt_script_type + if not dev_ver: + log.warning('WebUI falling back to "%s" mode.', script_type) + break + + scripts = component.get('Scripts').get_scripts(script_type) + scripts.insert(0, 'gettext.js') + + template = Template(filename=rpath('index.html')) + request.setHeader(b'content-type', b'text/html; charset=utf-8') + + web_config = component.get('Web').get_config() + web_config['base'] = request.base.decode() + config = {key: web_config[key] for key in UI_CONFIG_KEYS} + js_config = json.dumps(config) + # Insert the values into 'index.html' and return. + return template.render( + scripts=scripts, + stylesheets=self.stylesheets, + debug=str(bool(debug_arg)).lower(), + base=web_config['base'], + js_config=js_config, + ) + + +class DelugeWeb(component.Component): + def __init__(self, options=None, daemon=True): + """ + Setup the DelugeWeb server. + + Args: + options (argparse.Namespace): The web server options. + daemon (bool): If True run web server as a separate daemon process (starts a twisted + reactor). If False shares the process and twisted reactor from WebUI plugin or tests. + + """ + super().__init__('DelugeWeb', depend=['Web']) + self.config = configmanager.ConfigManager( + 'web.conf', defaults=CONFIG_DEFAULTS, file_version=2 + ) + self.config.run_converter((0, 1), 2, self._migrate_config_1_to_2) + self.config.register_set_function('language', self._on_language_changed) + self.socket = None + self.top_level = TopLevel() + + self.interface = self.config['interface'] + self.port = self.config['port'] + self.https = self.config['https'] + self.pkey = self.config['pkey'] + self.cert = self.config['cert'] + self.base = self.config['base'] + + if options: + self.interface = ( + options.interface if options.interface is not None else self.interface + ) + self.port = options.port if options.port else self.port + self.base = options.base if options.base else self.base + if options.ssl: + self.https = True + elif options.no_ssl: + self.https = False + + self.top_level.set_theme(self.config['theme']) + + setup_translation() + + # Remove twisted version number from 'server' http-header for security reasons + server.version = 'TwistedWeb' + self.site = server.Site(self.top_level) + self.web_api = WebApi() + self.web_utils = WebUtils() + + self.auth = Auth(self.config) + self.daemon = daemon + # Initialize the plugins + self.plugins = PluginManager() + + def _on_language_changed(self, key, value): + log.debug('Setting UI language %s', value) + set_language(value) + + def install_signal_handlers(self): + # Since twisted assigns itself all the signals may as well make + # use of it. + reactor.addSystemEventTrigger('after', 'shutdown', self.shutdown) + + # Twisted doesn't handle windows specific signals so we still + # need to attach to those to handle the close correctly. + if common.windows_check(): + from win32api import SetConsoleCtrlHandler + from win32con import CTRL_CLOSE_EVENT, CTRL_SHUTDOWN_EVENT + + def win_handler(ctrl_type): + log.debug('ctrl type: %s', ctrl_type) + if ctrl_type == CTRL_CLOSE_EVENT or ctrl_type == CTRL_SHUTDOWN_EVENT: + self.shutdown() + return 1 + + SetConsoleCtrlHandler(win_handler) + + def start(self): + """ + Start the DelugeWeb server + """ + if self.socket: + log.warning('DelugeWeb is already running and cannot be started') + return + + log.info('Starting webui server at PID %s', os.getpid()) + if self.https: + self.start_ssl() + else: + self.start_normal() + + component.get('Web').enable() + + if self.daemon: + reactor.run() + + def start_normal(self): + self.socket = reactor.listenTCP(self.port, self.site, interface=self.interface) + ip = self.socket.getHost().host + ip = f'[{ip}]' if is_ipv6(ip) else ip + log.info(f'Serving at http://{ip}:{self.port}{self.base}') + + def start_ssl(self): + check_ssl_keys() + log.debug('Enabling SSL with PKey: %s, Cert: %s', self.pkey, self.cert) + + cert = configmanager.get_config_dir(self.cert) + pkey = configmanager.get_config_dir(self.pkey) + + self.socket = reactor.listenSSL( + self.port, + self.site, + get_context_factory(cert, pkey), + interface=self.interface, + ) + ip = self.socket.getHost().host + ip = f'[{ip}]' if is_ipv6(ip) else ip + log.info(f'Serving at https://{ip}:{self.port}{self.base}') + + def stop(self): + log.info('Shutting down webserver') + try: + component.get('Web').disable() + except KeyError: + pass + + self.plugins.disable_plugins() + log.debug('Saving configuration file') + self.config.save() + + if self.socket: + d = self.socket.stopListening() + self.socket = None + else: + d = defer.Deferred() + d.callback(False) + return d + + def shutdown(self, *args): + self.stop() + if self.daemon and reactor.running: + reactor.stop() + + def _migrate_config_1_to_2(self, config): + config['language'] = CONFIG_DEFAULTS['language'] + return config + + def get_themes(self): + return self.top_level.get_themes() + + def set_theme(self, theme: str): + self.top_level.set_theme(theme) + + +if __name__ == '__builtin__': + deluge_web = DelugeWeb() + application = service.Application('DelugeWeb') + sc = service.IServiceCollection(application) + i = internet.TCPServer(deluge_web.port, deluge_web.site) + i.setServiceParent(sc) +elif __name__ == '__main__': + deluge_web = DelugeWeb() + deluge_web.start() -- cgit v1.2.3