From 0ac64da5c67209ba6c7b8870b1c6e42e6534b22b Mon Sep 17 00:00:00 2001 From: Damien Churchill Date: Thu, 12 Mar 2009 18:57:35 +0000 Subject: [PATCH] move the json resource into its own module split the json-rpc methods and the rpc server into seperate classes make the json-rpc server a component to allow for extension --- deluge/ui/web/auth.py | 30 +++ deluge/ui/web/common.py | 40 ++++ deluge/ui/web/json_api.py | 413 ++++++++++++++++++++++++++++++++++++++ deluge/ui/web/server.py | 354 +------------------------------- 4 files changed, 488 insertions(+), 349 deletions(-) create mode 100644 deluge/ui/web/auth.py create mode 100644 deluge/ui/web/common.py create mode 100644 deluge/ui/web/json_api.py diff --git a/deluge/ui/web/auth.py b/deluge/ui/web/auth.py new file mode 100644 index 000000000..6c5eaa460 --- /dev/null +++ b/deluge/ui/web/auth.py @@ -0,0 +1,30 @@ +# +# deluge/ui/web/auth.py +# +# Copyright (C) 2009 Damien Churchill +# +# Deluge is free software. +# +# You may 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. +# +# deluge 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 deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# + +AUTH_LEVEL_NONE = 0 +AUTH_LEVEL_READONLY = 1 +AUTH_LEVEL_NORMAL = 5 +AUTH_LEVEL_ADMIN = 10 + +AUTH_LEVEL_DEFAULT = AUTH_LEVEL_NORMAL \ No newline at end of file diff --git a/deluge/ui/web/common.py b/deluge/ui/web/common.py new file mode 100644 index 000000000..2579512b7 --- /dev/null +++ b/deluge/ui/web/common.py @@ -0,0 +1,40 @@ +# +# deluge/ui/web/common.py +# +# Copyright (C) 2009 Damien Churchill +# +# Deluge is free software. +# +# You may 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. +# +# deluge 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 deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# + +import gettext +from mako.template import Template as MakoTemplate +from deluge import common + +_ = gettext.gettext + +class Template(MakoTemplate): + + builtins = { + "_": _, + "version": common.get_version() + } + + def render(self, *args, **data): + data.update(self.builtins) + return MakoTemplate.render(self, *args, **data) \ No newline at end of file diff --git a/deluge/ui/web/json_api.py b/deluge/ui/web/json_api.py new file mode 100644 index 000000000..2c2d0c645 --- /dev/null +++ b/deluge/ui/web/json_api.py @@ -0,0 +1,413 @@ +# +# deluge/ui/web/json_api.py +# +# Copyright (C) 2009 Damien Churchill +# +# Deluge is free software. +# +# You may 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. +# +# deluge 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 deluge. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# + +import time +import hashlib +import logging + +from twisted.internet.defer import Deferred +from twisted.web import http, resource, server + +from deluge import common, component +from deluge.configmanager import ConfigManager +from deluge.ui.client import client, Client +from deluge.ui.web.auth import * +from deluge.ui.web.common import _ +json = common.json + +log = logging.getLogger(__name__) + +def export(auth_level=AUTH_LEVEL_DEFAULT): + """ + Decorator function to register an object's method as an RPC. The object + will need to be registered with an `:class:RPCServer` to be effective. + + :param func: function, the function to export + :param auth_level: int, the auth level required to call this method + + """ + def wrap(func, *args, **kwargs): + func._json_export = True + func._json_auth_level = auth_level + return func + + 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 = {} + + def connect(self, host="localhost", port=58846, username="", password=""): + """ + Connects the client to a daemon + """ + d = Deferred() + _d = client.connect(host, port, username, password) + + def on_get_methods(methods): + """ + Handles receiving the method names + """ + self._remote_methods = methods + methods = list(self._remote_methods) + methods.extend(self._local_methods) + d.callback(methods) + + def on_client_connected(connection_id): + """ + Handles the client successfully connecting to the daemon and + invokes retrieving the method names. + """ + d = client.daemon.get_method_list() + d.addCallback(on_get_methods) + _d.addCallback(on_client_connected) + return d + + def _exec_local(self, method, params): + """ + 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. + return self._local_methods[method](*params) + raise JSONException("Unknown system method") + + def _exec_remote(self, method, params): + """ + Executes methods using the Deluge client. + """ + component, method = method.split(".") + return getattr(getattr(client, 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. + """ + request_id = None + try: + request = json.loads(request) + except ValueError: + raise JSONException("JSON not decodable") + + if "method" not in request or "id" not in request or \ + "params" not in request: + raise JSONException("Invalid JSON request") + + method, params = request["method"], request["params"] + request_id = request["id"] + + try: + if method.startswith("system."): + return self._exec_local(method, params), request_id + elif method in self._local_methods: + return self._exec_local(method, params), request_id + elif method in self._remote_methods: + return self._exec_remote(method, params), request_id + except Exception, e: + log.exception(e) + d = Deferred() + d.callback(None) + return d, request_id + + 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 occured while making an rpc call. + """ + print type(reason) + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return "" + + 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. + """ + log.debug("json-request: %s", request.json) + response = {"result": None, "error": None, "id": None} + d, response["id"] = self._handle_request(request.json) + d.addCallback(self._on_rpc_request_finished, response, request) + d.addErrback(self._on_rpc_request_failed, response, request) + return d + + def _on_json_request_failed(self, reason, request): + """ + Errback handler to return a HTTP code of 500. + """ + log.exception(reason) + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return "" + + def _send_response(self, request, response): + response = json.dumps(response) + request.setHeader("content-type", "application/x-json") + request.write(response) + request.finish() + + def render(self, request): + """ + Handles all the POST requests made to the /json controller. + """ + + if request.method != "POST": + request.setResponseCode(http.NOT_ALLOWED) + return "" + + try: + request.content.seek(0) + request.json = request.content.read() + d = self._on_json_request(request) + return server.NOT_DONE_YET + except Exception, e: + return self._on_json_request_failed(e, 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: object, the object that we want to export + :param name: str, the name to use, if None, it will be the class name of the object + """ + 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) + +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) + + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 58846 + +DEFAULT_HOSTS = { + "hosts": [(hashlib.sha1(str(time.time())).hexdigest(), + DEFAULT_HOST, DEFAULT_PORT, "", "")] +} + +class WebApi(JSONComponent): + def __init__(self): + super(WebApi, self).__init__("Web") + self.host_list = ConfigManager("hostlist.conf.1.2", DEFAULT_HOSTS) + + @export() + def connect(self, host_id): + d = Deferred() + def on_connected(methods): + d.callback(methods) + for host in self.host_list["hosts"]: + if host_id != host[0]: + continue + self._json.connect(*host[1:]).addCallback(on_connected) + return d + + @export() + def connected(self): + d = Deferred() + d.callback(client.connected()) + return d + + @export() + def update_ui(self, keys, filter_dict): + + ui_info = { + "torrents": None, + "filters": None, + "stats": None + } + + d = Deferred() + + def got_stats(stats): + ui_info["stats"] = stats + d.callback(ui_info) + + def got_filters(filters): + ui_info["filters"] = filters + client.core.get_stats().addCallback(got_stats) + + def got_torrents(torrents): + ui_info["torrents"] = torrents + client.core.get_filter_tree().addCallback(got_filters) + client.core.get_torrents_status(filter_dict, keys).addCallback(got_torrents) + return d + + @export() + def download_torrent_from_url(self, url): + """ + input: + url: the url of the torrent to download + + returns: + filename: the temporary file name of the torrent file + """ + tmp_file = os.path.join(tempfile.gettempdir(), url.split("/")[-1]) + filename, headers = urllib.urlretrieve(url, tmp_file) + log.debug("filename: %s", filename) + d = Deferred() + d.callback(filename) + return d + + @export() + def get_torrent_info(self, filename): + """ + Goal: + allow the webui to retrieve data about the torrent + + input: + filename: the filename of the torrent to gather info about + + returns: + { + "filename": the torrent file + "name": the torrent name + "size": the total size of the torrent + "files": the files the torrent contains + "info_hash" the torrents info_hash + } + """ + d = Deferred() + d.callback(uicommon.get_torrent_info(filename.strip())) + return d + + @export() + def add_torrents(self, torrents): + """ + input: + torrents [{ + path: the path of the torrent file, + options: the torrent options + }] + """ + for torrent in torrents: + filename = os.path.basename(torrent["path"]) + fdump = open(torrent["path"], "r").read() + client.add_torrent_file(filename, fdump, torrent["options"]) + d = Deferred() + d.callback(True) + return d + + @export() + def login(self, password): + """Method to allow the webui to authenticate + """ + config = component.get("DelugeWeb").config + m = hashlib.md5() + m.update(config['pwd_salt']) + m.update(password) + d = Deferred() + d.callback(m.hexdigest() == config['pwd_md5']) + return d + + @export() + def get_hosts(self): + """Return the hosts in the hostlist""" + hosts = dict((host[0], host[:]) for host in self.host_list["hosts"]) + + main_deferred = Deferred() + def run_check(): + if all(map(lambda x: x[3] is not None, hosts.values())): + main_deferred.callback(hosts.values()) + + def on_connect(result, c, host_id): + def on_info(info, c): + hosts[host_id][3] = _("Online") + hosts[host_id][4] = info + c.disconnect() + run_check() + + def on_info_fail(reason): + hosts[host_id][3] = _("Offline") + run_check() + + if not c.connected(): + hosts[host_id][3] = _("Offline") + run_check() + return + + d = c.daemon.info() + d.addCallback(on_info, c) + d.addErrback(on_info_fail) + + def on_connect_failed(reason, host_id): + log.exception(reason) + hosts[host_id][3] = _("Offline") + run_check() + + for host in hosts.values(): + host_id, host, port, user, password = host[0:5] + hosts[host_id][3:4] = (None, None) + + if client.connected() and \ + (host, port, user) == client.connection_info(): + def on_info(info): + hosts[host_id][4] = info + run_check() + host[5] = _("Connected") + client.daemon.info().addCallback(on_info) + hosts[host_id] = host + continue + + c = Client() + d = c.connect(host, port, user, password) + d.addCallback(on_connect, c, host_id) + d.addErrback(on_connect_failed, host_id) + return main_deferred \ No newline at end of file diff --git a/deluge/ui/web/server.py b/deluge/ui/web/server.py index b368a83cc..e571a4b43 100644 --- a/deluge/ui/web/server.py +++ b/deluge/ui/web/server.py @@ -36,19 +36,16 @@ import pkg_resources from twisted.application import service, internet from twisted.internet import reactor -from twisted.internet.defer import Deferred from twisted.web import http, resource, server, static -from mako.template import Template as MakoTemplate - from deluge import common, component from deluge.configmanager import ConfigManager from deluge.log import setupLogger, LOG as _log from deluge.ui import common as uicommon -from deluge.ui.client import client, Client from deluge.ui.tracker_icons import TrackerIcons +from deluge.ui.web.common import Template +from deluge.ui.web.json_api import JSON, WebApi log = logging.getLogger(__name__) -json = common.json # Initialize gettext try: @@ -70,8 +67,8 @@ current_dir = os.path.dirname(__file__) CONFIG_DEFAULTS = { "port": 8112, "template": "slate", - "pwd_salt": u"2\xe8\xc7\xa6(n\x81_\x8f\xfc\xdf\x8b\xd1\x1e\xd5\x90", - "pwd_md5": u".\xe8w\\+\xec\xdb\xf2id4F\xdb\rUc", + "pwd_salt": "16f65d5c79b7e93278a28b60fed2431e", + "pwd_md5": "2c9baa929ca38fb5c9eb5b054474d1ce", "base": "", "sessions": [], "sidebar_show_zero": False, @@ -81,354 +78,12 @@ CONFIG_DEFAULTS = { "https": False } -DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 58846 -DEFAULT_HOSTS = { - "hosts": [[hashlib.sha1(str(time.time())).hexdigest(), DEFAULT_HOST, DEFAULT_PORT, "", ""]] -} - -HOSTLIST_COL_ID = 0 -HOSTLIST_COL_HOST = 1 -HOSTLIST_COL_PORT = 2 -HOSTLIST_COL_STATUS = 3 -HOSTLIST_COL_USER = 4 -HOSTLIST_COL_PASS = 5 -HOSTLIST_COL_VERSION = 6 - -hostlist = ConfigManager("hostlist.conf.1.2", DEFAULT_HOSTS) - def rpath(path): """Convert a relative path into an absolute path relative to the location of this script. """ return os.path.join(current_dir, path) -class Template(MakoTemplate): - - builtins = { - "_": _, - "version": common.get_version() - } - - def render(self, *args, **data): - data.update(self.builtins) - return MakoTemplate.render(self, *args, **data) - -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 = { - "web.update_ui": self.update_ui, - "web.download_torrent_from_url": self.download_torrent_from_url, - "web.get_torrent_info": self.get_torrent_info, - "web.add_torrents": self.add_torrents, - "web.login": self.login, - "web.get_hosts": self.get_hosts, - "web.connect": self.connect, - "web.connected": self.connected - } - - def _connect(self, host="localhost", port=58846, username="", password=""): - """ - Connects the client to a daemon - """ - d = Deferred() - _d = client.connect(host, port, username, password) - - def on_get_methods(methods): - """ - Handles receiving the method names - """ - self._remote_methods = methods - methods = list(self._remote_methods) - methods.extend(self._local_methods) - d.callback(methods) - - def on_client_connected(connection_id): - """ - Handles the client successfully connecting to the daemon and - invokes retrieving the method names. - """ - d = client.daemon.get_method_list() - d.addCallback(on_get_methods) - _d.addCallback(on_client_connected) - return d - - def _exec_local(self, method, params): - """ - 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. - return self._local_methods[method](*params) - raise JSONException("Unknown system method") - - def _exec_remote(self, method, params): - """ - Executes methods using the Deluge client. - """ - component, method = method.split(".") - return getattr(getattr(client, 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. - """ - request_id = None - try: - request = json.loads(request) - except ValueError: - raise JSONException("JSON not decodable") - - if "method" not in request or "id" not in request or \ - "params" not in request: - raise JSONException("Invalid JSON request") - - method, params = request["method"], request["params"] - request_id = request["id"] - - try: - if method.startswith("system."): - return self._exec_local(method, params), request_id - elif method in self._local_methods: - return self._exec_local(method, params), request_id - elif method in self._remote_methods: - return self._exec_remote(method, params), request_id - except Exception, e: - log.exception(e) - d = Deferred() - d.callback(None) - return d, request_id - - 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 occured while making an rpc call. - """ - print type(reason) - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return "" - - 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. - """ - log.debug("json-request: %s", request.json) - response = {"result": None, "error": None, "id": None} - d, response["id"] = self._handle_request(request.json) - d.addCallback(self._on_rpc_request_finished, response, request) - d.addErrback(self._on_rpc_request_failed, response, request) - return d - - def _on_json_request_failed(self, reason, request): - """ - Errback handler to return a HTTP code of 500. - """ - log.exception(reason) - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return "" - - def _send_response(self, request, response): - response = json.dumps(response) - request.setHeader("content-type", "application/x-json") - request.write(response) - request.finish() - - def render(self, request): - """ - Handles all the POST requests made to the /json controller. - """ - - if request.method != "POST": - request.setResponseCode(http.NOT_ALLOWED) - return "" - - try: - request.content.seek(0) - request.json = request.content.read() - d = self._on_json_request(request) - return server.NOT_DONE_YET - except Exception, e: - return self._on_json_request_failed(e, request) - - def connect(self, host_id): - d = Deferred() - def on_connected(methods): - d.callback(methods) - for host in hostlist["hosts"]: - if host_id != host[0]: - continue - self._connect(*host[1:]).addCallback(on_connected) - return d - - def connected(self): - d = Deferred() - d.callback(client.connected()) - return d - - def update_ui(self, keys, filter_dict): - - ui_info = { - "torrents": None, - "filters": None, - "stats": None - } - - d = Deferred() - - def got_stats(stats): - ui_info["stats"] = stats - d.callback(ui_info) - - def got_filters(filters): - ui_info["filters"] = filters - client.core.get_stats().addCallback(got_stats) - - def got_torrents(torrents): - ui_info["torrents"] = torrents - client.core.get_filter_tree().addCallback(got_filters) - client.core.get_torrents_status(filter_dict, keys).addCallback(got_torrents) - return d - - def download_torrent_from_url(self, url): - """ - input: - url: the url of the torrent to download - - returns: - filename: the temporary file name of the torrent file - """ - tmp_file = os.path.join(tempfile.gettempdir(), url.split("/")[-1]) - filename, headers = urllib.urlretrieve(url, tmp_file) - log.debug("filename: %s", filename) - d = Deferred() - d.callback(filename) - return d - - def get_torrent_info(self, filename): - """ - Goal: - allow the webui to retrieve data about the torrent - - input: - filename: the filename of the torrent to gather info about - - returns: - { - "filename": the torrent file - "name": the torrent name - "size": the total size of the torrent - "files": the files the torrent contains - "info_hash" the torrents info_hash - } - """ - d = Deferred() - d.callback(uicommon.get_torrent_info(filename.strip())) - return d - - def add_torrents(self, torrents): - """ - input: - torrents [{ - path: the path of the torrent file, - options: the torrent options - }] - """ - for torrent in torrents: - filename = os.path.basename(torrent["path"]) - fdump = open(torrent["path"], "r").read() - client.add_torrent_file(filename, fdump, torrent["options"]) - d = Deferred() - d.callback(True) - return d - - def login(self, password): - """Method to allow the webui to authenticate - """ - config = component.get("DelugeWeb").config - m = hashlib.md5() - m.update(config['pwd_salt']) - m.update(password) - d = Deferred() - d.callback(m.digest() == config['pwd_md5']) - return d - - def get_hosts(self): - """Return the hosts in the hostlist""" - hosts = dict((host[0], host[:]) for host in hostlist["hosts"]) - - main_deferred = Deferred() - def run_check(): - if all(map(lambda x: x[3] is not None, hosts.values())): - main_deferred.callback(hosts.values()) - - def on_connect(result, c, host_id): - def on_info(info, c): - hosts[host_id][3] = _("Online") - hosts[host_id][4] = info - c.disconnect() - run_check() - - def on_info_fail(reason): - hosts[host_id][3] = _("Offline") - run_check() - - d = c.daemon.info() - d.addCallback(on_info, c) - d.addErrback(on_info_fail, c) - - def on_connect_failed(reason, host_id): - log.exception(reason) - hosts[host_id][3] = _("Offline") - run_check() - - for host in hosts.values(): - host_id, host, port, user, password = host[0:5] - hosts[host_id][3:4] = (None, None) - - if client.connected() and (host, port, user) == client.connection_info(): - def on_info(info): - hosts[host_id][4] = info - run_check() - host[5] = _("Connected") - client.daemon.info().addCallback(on_info) - hosts[host_id] = host - continue - - c = Client() - d = c.connect(host, port, user, password) - d.addCallback(on_connect, c, host_id) - d.addErrback(on_connect_failed, host_id) - return main_deferred - class GetText(resource.Resource): def render(self, request): request.setHeader("content-type", "text/javascript") @@ -570,6 +225,7 @@ class DelugeWeb(component.Component): self.site = server.Site(TopLevel()) self.config = ConfigManager("web.conf", CONFIG_DEFAULTS) self.port = self.config["port"] + self.web_api = WebApi() signal.signal(signal.SIGINT, self.shutdown) signal.signal(signal.SIGTERM, self.shutdown)