deluge/deluge/ui/web/json_api.py
2018-10-10 17:57:02 +01:00

995 lines
32 KiB
Python

# -*- coding: utf-8 -*-
#
# Copyright (C) 2009-2010 Damien Churchill <damoxc@gmail.com>
#
# 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.
#
from __future__ import division, unicode_literals
import json
import logging
import os
import shutil
import tempfile
from base64 import b64encode
from types import FunctionType
from xml.sax.saxutils import escape as xml_escape
from twisted.internet import defer, reactor
from twisted.internet.defer import Deferred, DeferredList
from twisted.web import http, resource, server
from deluge import component, httpdownloader
from deluge.common import AUTH_LEVEL_DEFAULT, get_magnet_info, is_magnet
from deluge.configmanager import get_config_dir
from deluge.error import NotAuthorizedError
from deluge.ui.client import Client, client
from deluge.ui.common import FileTree2, TorrentInfo
from deluge.ui.coreconfig import CoreConfig
from deluge.ui.hostlist import HostList
from deluge.ui.sessionproxy import SessionProxy
from deluge.ui.translations_util import get_languages
from deluge.ui.web.common import _, compress
log = logging.getLogger(__name__)
class JSONComponent(component.Component):
def __init__(self, name, interval=1, depend=None):
super(JSONComponent, self).__init__(name, interval, depend)
self._json = component.get('JSON')
self._json.register_object(self, name)
def export(auth_level=AUTH_LEVEL_DEFAULT):
"""
Decorator function to register an object's method as a RPC. The object
will need to be registered with a `:class:JSON` to be effective.
:param func: the function to export
:type func: function
:param auth_level: the auth level required to call this method
:type auth_level: int
"""
def wrap(func, *args, **kwargs):
func._json_export = True
func._json_auth_level = auth_level
return func
if isinstance(auth_level, FunctionType):
func = auth_level
auth_level = AUTH_LEVEL_DEFAULT
return wrap(func)
else:
return wrap
class JSONException(Exception):
def __init__(self, inner_exception):
self.inner_exception = inner_exception
Exception.__init__(self, str(inner_exception))
class JSON(resource.Resource, component.Component):
"""
A Twisted Web resource that exposes a JSON-RPC interface for web clients \
to use.
"""
def __init__(self):
resource.Resource.__init__(self)
component.Component.__init__(self, 'JSON')
self._remote_methods = []
self._local_methods = {}
if client.is_standalone():
self.get_remote_methods()
def get_remote_methods(self, result=None):
"""
Updates remote methods from the daemon.
Returns:
t.i.d.Deferred: A deferred returning the available remote methods
"""
def on_get_methods(methods):
self._remote_methods = methods
return methods
return client.daemon.get_method_list().addCallback(on_get_methods)
def _exec_local(self, method, params, request):
"""
Handles executing all local methods.
"""
if method == 'system.listMethods':
d = Deferred()
methods = list(self._remote_methods)
methods.extend(self._local_methods)
d.callback(methods)
return d
elif method in self._local_methods:
# This will eventually process methods that the server adds
# and any plugins.
meth = self._local_methods[method]
meth.__globals__['__request__'] = request
component.get('Auth').check_request(request, meth)
return meth(*params)
raise JSONException('Unknown system method')
def _exec_remote(self, method, params, request):
"""
Executes methods using the Deluge client.
"""
component.get('Auth').check_request(request, level=AUTH_LEVEL_DEFAULT)
core_component, method = method.split('.')
return getattr(getattr(client, core_component), method)(*params)
def _handle_request(self, request):
"""
Takes some json data as a string and attempts to decode it, and process
the rpc object that should be contained, returning a deferred for all
procedure calls and the request id.
"""
try:
request_data = json.loads(request.json.decode())
except (ValueError, TypeError):
raise JSONException('JSON not decodable')
try:
method = request_data['method']
params = request_data['params']
request_id = request_data['id']
except KeyError as ex:
message = 'Invalid JSON request, missing param %s in %s' % (
ex,
request_data,
)
raise JSONException(message)
result = None
error = None
try:
if method.startswith('system.') or method in self._local_methods:
result = self._exec_local(method, params, request)
elif method in self._remote_methods:
result = self._exec_remote(method, params, request)
else:
error = {'message': 'Unknown method', 'code': 2}
except NotAuthorizedError:
error = {'message': 'Not authenticated', 'code': 1}
except Exception as ex:
log.error('Error calling method `%s`: %s', method, ex)
log.exception(ex)
error = {'message': '%s: %s' % (ex.__class__.__name__, str(ex)), 'code': 3}
return request_id, result, error
def _on_rpc_request_finished(self, result, response, request):
"""
Sends the response of any rpc calls back to the json-rpc client.
"""
response['result'] = result
return self._send_response(request, response)
def _on_rpc_request_failed(self, reason, response, request):
"""
Handles any failures that occurred while making an rpc call.
"""
log.error(reason)
response['error'] = {
'message': '%s: %s' % (reason.__class__.__name__, str(reason)),
'code': 4,
}
return self._send_response(request, response)
def _on_json_request(self, request):
"""
Handler to take the json data as a string and pass it on to the
_handle_request method for further processing.
"""
content_type = request.getHeader(b'content-type').decode()
if content_type != 'application/json':
message = 'Invalid JSON request content-type: %s' % content_type
raise JSONException(message)
log.debug('json-request: %s', request.json)
response = {'result': None, 'error': None, 'id': None}
response['id'], d, response['error'] = self._handle_request(request)
if isinstance(d, Deferred):
d.addCallback(self._on_rpc_request_finished, response, request)
d.addErrback(self._on_rpc_request_failed, response, request)
return d
else:
response['result'] = d
return self._send_response(request, response)
def _on_json_request_failed(self, reason, request):
"""
Returns the error in json response.
"""
log.error(reason)
response = {
'result': None,
'id': None,
'error': {
'code': 5,
'message': '%s: %s' % (reason.__class__.__name__, str(reason)),
},
}
return self._send_response(request, response)
def _send_response(self, request, response):
if request._disconnected:
return ''
response = json.dumps(response)
request.setHeader(b'content-type', b'application/json')
request.write(compress(response.encode(), request))
request.finish()
return server.NOT_DONE_YET
def render(self, request):
"""
Handles all the POST requests made to the /json controller.
"""
if request.method != b'POST':
request.setResponseCode(http.NOT_ALLOWED)
request.finish()
return server.NOT_DONE_YET
try:
request.content.seek(0)
request.json = request.content.read()
self._on_json_request(request)
return server.NOT_DONE_YET
except Exception as ex:
return self._on_json_request_failed(ex, request)
def register_object(self, obj, name=None):
"""
Registers an object to export it's rpc methods. These methods should
be exported with the export decorator prior to registering the object.
:param obj: the object that we want to export
:type obj: object
:param name: the name to use, if None, it will be the class name of the object
:type name: string
"""
name = name or obj.__class__.__name__
name = name.lower()
for d in dir(obj):
if d[0] == '_':
continue
if getattr(getattr(obj, d), '_json_export', False):
log.debug('Registering method: %s', name + '.' + d)
self._local_methods[name + '.' + d] = getattr(obj, d)
FILES_KEYS = ['files', 'file_progress', 'file_priorities']
class EventQueue(object):
"""
This class subscribes to events from the core and stores them until all
the subscribed listeners have received the events.
"""
def __init__(self):
self.__events = {}
self.__handlers = {}
self.__queue = {}
self.__requests = {}
def add_listener(self, listener_id, event):
"""
Add a listener to the event queue.
:param listener_id: A unique id for the listener
:type listener_id: string
:param event: The event name
:type event: string
"""
if event not in self.__events:
def on_event(*args):
for listener in self.__events[event]:
if listener not in self.__queue:
self.__queue[listener] = []
self.__queue[listener].append((event, args))
client.register_event_handler(event, on_event)
self.__handlers[event] = on_event
self.__events[event] = [listener_id]
elif listener_id not in self.__events[event]:
self.__events[event].append(listener_id)
def get_events(self, listener_id):
"""
Retrieve the pending events for the listener.
:param listener_id: A unique id for the listener
:type listener_id: string
"""
# Check to see if we have anything to return immediately
if listener_id in self.__queue:
queue = self.__queue[listener_id]
del self.__queue[listener_id]
return queue
# Create a deferred to and check again in 100ms
d = Deferred()
reactor.callLater(0.1, self._get_events, listener_id, 0, d)
return d
def _get_events(self, listener_id, count, d):
if listener_id in self.__queue:
queue = self.__queue[listener_id]
del self.__queue[listener_id]
d.callback(queue)
else:
# Prevent this loop going on indefinitely incase a client leaves
# the page or disconnects uncleanly.
if count >= 50:
d.callback(None)
else:
reactor.callLater(0.1, self._get_events, listener_id, count + 1, d)
def remove_listener(self, listener_id, event):
"""
Remove a listener from the event queue.
:param listener_id: The unique id for the listener
:type listener_id: string
:param event: The event name
:type event: string
"""
self.__events[event].remove(listener_id)
if not self.__events[event]:
client.deregister_event_handler(event, self.__handlers[event])
del self.__events[event]
del self.__handlers[event]
class WebApi(JSONComponent):
"""
The component that implements all the methods required for managing
the web interface. The complete web json interface also exposes all the
methods available from the core RPC.
"""
XSS_VULN_KEYS = ['name', 'message', 'comment', 'tracker_status', 'peers']
def __init__(self):
super(WebApi, self).__init__('Web', depend=['SessionProxy'])
self.hostlist = HostList()
self.core_config = CoreConfig()
self.event_queue = EventQueue()
try:
self.sessionproxy = component.get('SessionProxy')
except KeyError:
self.sessionproxy = SessionProxy()
def disable(self):
client.deregister_event_handler(
'PluginEnabledEvent', self._json.get_remote_methods
)
client.deregister_event_handler(
'PluginDisabledEvent', self._json.get_remote_methods
)
if client.is_standalone():
component.get('Web.PluginManager').stop()
else:
client.disconnect()
client.set_disconnect_callback(None)
def enable(self):
client.register_event_handler(
'PluginEnabledEvent', self._json.get_remote_methods
)
client.register_event_handler(
'PluginDisabledEvent', self._json.get_remote_methods
)
if client.is_standalone():
component.get('Web.PluginManager').start()
else:
client.set_disconnect_callback(self._on_client_disconnect)
default_host_id = component.get('DelugeWeb').config['default_daemon']
if default_host_id:
return self.connect(default_host_id)
return defer.succeed(True)
def _on_client_connect(self, *args):
"""Handles client successfully connecting to the daemon.
Invokes retrieving the method names and starts webapi and plugins.
"""
d_methods = self._json.get_remote_methods()
component.get('Web.PluginManager').start()
self.start()
return d_methods
def _on_client_disconnect(self, *args):
component.get('Web.PluginManager').stop()
return self.stop()
def start(self):
self.core_config.start()
return self.sessionproxy.start()
def stop(self):
self.core_config.stop()
self.sessionproxy.stop()
return defer.succeed(True)
@export
def connect(self, host_id):
"""Connect the web client to a daemon.
Args:
host_id (str): The id of the daemon in the host list.
Returns:
Deferred: List of methods the daemon supports.
"""
return self.hostlist.connect_host(host_id).addCallback(self._on_client_connect)
@export
def connected(self):
"""
The current connection state.
:returns: True if the client is connected
:rtype: booleon
"""
return client.connected()
@export
def disconnect(self):
"""
Disconnect the web interface from the connected daemon.
"""
d = client.disconnect()
def on_disconnect(reason):
return str(reason)
d.addCallback(on_disconnect)
return d
@export
def update_ui(self, keys, filter_dict):
"""
Gather the information required for updating the web interface.
:param keys: the information about the torrents to gather
:type keys: list
:param filter_dict: the filters to apply when selecting torrents.
:type filter_dict: dictionary
:returns: The torrent and ui information.
:rtype: dictionary
"""
d = Deferred()
ui_info = {
'connected': client.connected(),
'torrents': None,
'filters': None,
'stats': {
'max_download': self.core_config.get('max_download_speed'),
'max_upload': self.core_config.get('max_upload_speed'),
'max_num_connections': self.core_config.get('max_connections_global'),
},
}
if not client.connected():
d.callback(ui_info)
return d
def got_stats(stats):
ui_info['stats']['num_connections'] = stats['num_peers']
ui_info['stats']['upload_rate'] = stats['payload_upload_rate']
ui_info['stats']['download_rate'] = stats['payload_download_rate']
ui_info['stats']['download_protocol_rate'] = (
stats['download_rate'] - stats['payload_download_rate']
)
ui_info['stats']['upload_protocol_rate'] = (
stats['upload_rate'] - stats['payload_upload_rate']
)
ui_info['stats']['dht_nodes'] = stats['dht_nodes']
ui_info['stats']['has_incoming_connections'] = stats[
'has_incoming_connections'
]
def got_filters(filters):
ui_info['filters'] = filters
def got_free_space(free_space):
ui_info['stats']['free_space'] = free_space
def got_external_ip(external_ip):
ui_info['stats']['external_ip'] = external_ip
def got_torrents(torrents):
ui_info['torrents'] = torrents
def on_complete(result):
d.callback(ui_info)
d1 = component.get('SessionProxy').get_torrents_status(filter_dict, keys)
d1.addCallback(got_torrents)
d2 = client.core.get_filter_tree()
d2.addCallback(got_filters)
d3 = client.core.get_session_status(
[
'num_peers',
'payload_download_rate',
'payload_upload_rate',
'download_rate',
'upload_rate',
'dht_nodes',
'has_incoming_connections',
]
)
d3.addCallback(got_stats)
d4 = client.core.get_free_space(self.core_config.get('download_location'))
d4.addCallback(got_free_space)
d5 = client.core.get_external_ip()
d5.addCallback(got_external_ip)
dl = DeferredList([d1, d2, d3, d4, d5], consumeErrors=True)
dl.addCallback(on_complete)
return d
def _on_got_files(self, torrent, d):
files = torrent.get('files')
file_progress = torrent.get('file_progress')
file_priorities = torrent.get('file_priorities')
paths = []
info = {}
for index, torrent_file in enumerate(files):
path = xml_escape(torrent_file['path'])
paths.append(path)
torrent_file['progress'] = file_progress[index]
torrent_file['priority'] = file_priorities[index]
torrent_file['index'] = index
torrent_file['path'] = path
info[path] = torrent_file
# update the directory info
dirname = os.path.dirname(path)
while dirname:
dirinfo = info.setdefault(dirname, {})
dirinfo['size'] = dirinfo.get('size', 0) + torrent_file['size']
if 'priority' not in dirinfo:
dirinfo['priority'] = torrent_file['priority']
else:
if dirinfo['priority'] != torrent_file['priority']:
dirinfo['priority'] = 9
progresses = dirinfo.setdefault('progresses', [])
progresses.append(torrent_file['size'] * torrent_file['progress'] / 100)
dirinfo['progress'] = sum(progresses) / dirinfo['size'] * 100
dirinfo['path'] = dirname
dirname = os.path.dirname(dirname)
def walk(path, item):
if item['type'] == 'dir':
item.update(info[path])
return item
else:
item.update(info[path])
return item
file_tree = FileTree2(paths)
file_tree.walk(walk)
d.callback(file_tree.get_tree())
def _on_torrent_status(self, torrent, d):
for key in self.XSS_VULN_KEYS:
try:
if key == 'peers':
for peer in torrent[key]:
peer['client'] = xml_escape(peer['client'])
else:
torrent[key] = xml_escape(torrent[key])
except KeyError:
pass
d.callback(torrent)
@export
def get_torrent_status(self, torrent_id, keys):
main_deferred = Deferred()
d = component.get('SessionProxy').get_torrent_status(torrent_id, keys)
d.addCallback(self._on_torrent_status, main_deferred)
return main_deferred
@export
def get_torrent_files(self, torrent_id):
"""
Gets the files for a torrent in tree format
:param torrent_id: the id of the torrent to retrieve.
:type torrent_id: string
:returns: The torrents files in a tree
:rtype: dictionary
"""
main_deferred = Deferred()
d = component.get('SessionProxy').get_torrent_status(torrent_id, FILES_KEYS)
d.addCallback(self._on_got_files, main_deferred)
return main_deferred
@export
def download_torrent_from_url(self, url, cookie=None):
"""
Download a torrent file from a url to a temporary directory.
:param url: the url of the torrent
:type url: string
:returns: the temporary file name of the torrent file
:rtype: string
"""
def on_download_success(result):
log.debug('Successfully downloaded %s to %s', url, result)
return result
def on_download_fail(result):
log.error('Failed to add torrent from url %s', url)
return result
tempdir = tempfile.mkdtemp(prefix='delugeweb-')
tmp_file = os.path.join(tempdir, url.split('/')[-1])
log.debug('filename: %s', tmp_file)
headers = {}
if cookie:
headers['Cookie'] = cookie
log.debug('cookie: %s', cookie)
d = httpdownloader.download_file(url, tmp_file, headers=headers)
d.addCallbacks(on_download_success, on_download_fail)
return d
@export
def get_torrent_info(self, filename):
"""
Return information about a torrent on the filesystem.
:param filename: the path to the torrent
:type filename: string
:returns: information about the torrent:
::
{
"name": the torrent name,
"files_tree": the files the torrent contains,
"info_hash" the torrents info_hash
}
:rtype: dictionary
"""
try:
torrent_info = TorrentInfo(filename.strip(), 2)
return torrent_info.as_dict('name', 'info_hash', 'files_tree')
except Exception as ex:
log.error(ex)
return False
@export
def get_magnet_info(self, uri):
return get_magnet_info(uri)
@export
def add_torrents(self, torrents):
"""
Add torrents by file
:param torrents: A list of dictionaries containing the torrent \
path and torrent options to add with.
:type torrents: list
::
json_api.web.add_torrents([{
"path": "/tmp/deluge-web/some-torrent-file.torrent",
"options": {"download_location": "/home/deluge/"}
}])
"""
deferreds = []
for torrent in torrents:
if is_magnet(torrent['path']):
log.info(
'Adding torrent from magnet uri `%s` with options `%r`',
torrent['path'],
torrent['options'],
)
d = client.core.add_torrent_magnet(torrent['path'], torrent['options'])
deferreds.append(d)
else:
filename = os.path.basename(torrent['path'])
with open(torrent['path'], 'rb') as _file:
fdump = b64encode(_file.read())
log.info(
'Adding torrent from file `%s` with options `%r`',
filename,
torrent['options'],
)
d = client.core.add_torrent_file_async(
filename, fdump, torrent['options']
)
deferreds.append(d)
return DeferredList(deferreds, consumeErrors=False)
def _get_host(self, host_id):
"""Information about a host from supplied host id.
Args:
host_id (str): The id of the host.
Returns:
list: The host information, empty list if not found.
"""
return list(self.hostlist.get_host_info(host_id))
@export
def get_hosts(self):
"""
Return the hosts in the hostlist.
"""
log.debug('get_hosts called')
return self.hostlist.get_hosts_info()
@export
def get_host_status(self, host_id):
"""
Returns the current status for the specified host.
:param host_id: the hash id of the host
:type host_id: string
"""
def response(result):
return result
return self.hostlist.get_host_status(host_id).addCallback(response)
@export
def add_host(self, host, port, username='', password=''):
"""Adds a host to the list.
Args:
host (str): The IP or hostname of the deluge daemon.
port (int): The port of the deluge daemon.
username (str): The username to login to the daemon with.
password (str): The password to login to the daemon with.
Returns:
tuple: A tuple of (bool, str). If True will contain the host_id, otherwise
if False will contain the error message.
"""
try:
host_id = self.hostlist.add_host(host, port, username, password)
except ValueError as ex:
return False, str(ex)
else:
return True, host_id
@export
def edit_host(self, host_id, host, port, username='', password=''):
"""Edit host details in the hostlist.
Args:
host_id (str): The host identifying hash.
host (str): The IP or hostname of the deluge daemon.
port (int): The port of the deluge daemon.
username (str): The username to login to the daemon with.
password (str): The password to login to the daemon with.
Returns:
bool: True if succesful, False otherwise.
"""
return self.hostlist.update_host(host_id, host, port, username, password)
@export
def remove_host(self, host_id):
"""Removes a host from the hostlist.
Args:
host_id (str): The host identifying hash.
Returns:
bool: True if succesful, False otherwise.
"""
return self.hostlist.remove_host(host_id)
@export
def start_daemon(self, port):
"""
Starts a local daemon.
"""
client.start_daemon(port, get_config_dir())
@export
def stop_daemon(self, host_id):
"""
Stops a running daemon.
:param host_id: the hash id of the host
:type host_id: string
"""
main_deferred = Deferred()
host = self._get_host(host_id)
if not host:
main_deferred.callback((False, _('Daemon does not exist')))
return main_deferred
try:
def on_connect(connected, c):
if not connected:
main_deferred.callback((False, _('Daemon not running')))
return
c.daemon.shutdown()
main_deferred.callback((True,))
def on_connect_failed(reason):
main_deferred.callback((False, reason))
host, port, user, password = host[1:5]
c = Client()
d = c.connect(host, port, user, password)
d.addCallback(on_connect, c)
d.addErrback(on_connect_failed)
except Exception:
main_deferred.callback((False, 'An error occurred'))
return main_deferred
@export
def get_config(self):
"""
Get the configuration dictionary for the web interface.
:rtype: dictionary
:returns: the configuration
"""
config = component.get('DelugeWeb').config.config.copy()
del config['sessions']
del config['pwd_salt']
del config['pwd_sha1']
return config
@export
def set_config(self, config):
"""
Sets the configuration dictionary for the web interface.
:param config: The configuration options to update
:type config: dictionary
"""
web_config = component.get('DelugeWeb').config
for key in config:
if key in ['sessions', 'pwd_salt', 'pwd_sha1']:
log.warning('Ignored attempt to overwrite web config key: %s', key)
continue
web_config[key] = config[key]
@export
def get_plugins(self):
"""All available and enabled plugins within WebUI.
Note:
This does not represent all plugins from deluge.client.core.
Returns:
dict: A dict containing 'available_plugins' and 'enabled_plugins' lists.
"""
return {
'enabled_plugins': list(component.get('Web.PluginManager').plugins),
'available_plugins': component.get('Web.PluginManager').available_plugins,
}
@export
def get_plugin_info(self, name):
return component.get('Web.PluginManager').get_plugin_info(name)
@export
def get_plugin_resources(self, name):
return component.get('Web.PluginManager').get_plugin_resources(name)
@export
def upload_plugin(self, filename, path):
main_deferred = Deferred()
shutil.copyfile(path, os.path.join(get_config_dir(), 'plugins', filename))
component.get('Web.PluginManager').scan_for_plugins()
if client.is_localhost():
client.core.rescan_plugins()
return True
with open(path, 'rb') as _file:
plugin_data = b64encode(_file.read())
def on_upload_complete(*args):
client.core.rescan_plugins()
component.get('Web.PluginManager').scan_for_plugins()
main_deferred.callback(True)
def on_upload_error(*args):
main_deferred.callback(False)
d = client.core.upload_plugin(filename, plugin_data)
d.addCallback(on_upload_complete)
d.addErrback(on_upload_error)
return main_deferred
@export
def register_event_listener(self, event):
"""
Add a listener to the event queue.
:param event: The event name
:type event: string
"""
self.event_queue.add_listener(__request__.session_id, event)
@export
def deregister_event_listener(self, event):
"""
Remove an event listener from the event queue.
:param event: The event name
:type event: string
"""
self.event_queue.remove_listener(__request__.session_id, event)
@export
def get_events(self):
"""
Retrieve the pending events for the session.
"""
return self.event_queue.get_events(__request__.session_id)
class WebUtils(JSONComponent):
"""
Utility functions for the webui that do not fit in the WebApi.
"""
def __init__(self):
super(WebUtils, self).__init__('WebUtils')
@export
def get_languages(self):
"""
Get the available translated languages
Returns:
list: of tuples [(lang-id, language-name), ...]
"""
return get_languages()