# -*- coding: utf-8 -*-
# pylint: disable=bad-whitespace
""" rTorrent web apps.
Copyright (c) 2013 The PyroScope Project <pyroscope.project@gmail.com>
"""
# 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 2 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, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from __future__ import absolute_import
import os
import re
import json
import time
import socket
#import mimetypes
import psutil
from webob import exc, static, Request, Response
from webob.dec import wsgify
#from webob.response import Response
from pyrobase.parts import Bunch
from pyrocore import config, error
from pyrocore.util import pymagic, xmlrpc, stats
[docs]class StaticFolders(object):
""" An application that serves up the files in a list of given directories.
Non-existent paths are ignored.
Pass a `fileapp` factory to change the default file serving app.
"""
def __init__(self, paths, fileapp=None, **kw):
self.LOG = pymagic.get_class_logger(self)
self.paths = []
self.fileapp = fileapp or static.FileApp
self.fileapp_kw = kw
for path in paths:
path = os.path.abspath(path).rstrip(os.path.sep) + os.path.sep
if os.path.isdir(path):
self.paths.append(path)
else:
self.LOG.warn("Static HTTP directory %r not found, ignoring it" % path)
@wsgify
def __call__(self, req):
urlpath = req.urlvars.filepath.strip('/').replace("..", "!FORBIDDEN!")
for basepath in self.paths:
path = os.path.abspath(os.path.realpath(os.path.join(basepath, urlpath)))
if not os.path.isfile(path):
continue
elif not path.startswith(basepath):
return exc.HTTPForbidden(comment="Won't follow symlink to %r" % urlpath)
else:
return self.fileapp(path, **self.fileapp_kw)
return exc.HTTPNotFound(comment=urlpath)
[docs]class JsonController(object):
""" Controller for generating JSON data.
"""
ERRORS_LOGGED = set()
def __init__(self, **kwargs):
self.LOG = pymagic.get_class_logger(self)
self.cfg = Bunch(kwargs)
@wsgify
def __call__(self, req):
action = req.urlvars.get("action")
try:
try:
method = getattr(self, "json_" + action)
except AttributeError:
raise exc.HTTPNotFound("No action '%s'" % action)
resp = method(req)
if isinstance(resp, (dict, list)):
try:
resp = json.dumps(resp, sort_keys=True)
except (TypeError, ValueError, IndexError, AttributeError) as json_exc:
raise exc.HTTPInternalServerError("JSON serialization error (%s)" % json_exc)
if isinstance(resp, basestring):
resp = Response(body=resp, content_type="application/json")
except exc.HTTPException as http_exc:
resp = http_exc
return resp
[docs] def guarded(self, func, *args, **kwargs):
""" Call a function, return None on errors.
"""
try:
return func(*args, **kwargs)
except (EnvironmentError, error.LoggableError, xmlrpc.ERRORS) as g_exc:
if func.__name__ not in self.ERRORS_LOGGED:
self.LOG.warn("While calling '%s': %s" % (func.__name__, g_exc))
self.ERRORS_LOGGED.add(func.__name__)
return None
[docs] def json_engine(self, req): # pylint: disable=R0201,W0613
""" Return torrent engine data.
"""
try:
return stats.engine_data(config.engine)
except (error.LoggableError, xmlrpc.ERRORS) as torrent_exc:
raise exc.HTTPInternalServerError(str(torrent_exc))
[docs] def json_charts(self, req):
""" Return charting data.
"""
disk_used, disk_total, disk_detail = 0, 0, []
for disk_usage_path in self.cfg.disk_usage_path.split(os.pathsep):
disk_usage = self.guarded(psutil.disk_usage, os.path.expanduser(disk_usage_path.strip()))
if disk_usage:
disk_used += disk_usage.used
disk_total += disk_usage.total
disk_detail.append((disk_usage.used, disk_usage.total))
data = dict(
engine = self.json_engine(req),
uptime = time.time() - psutil.BOOT_TIME, # pylint: disable=no-member
fqdn = self.guarded(socket.getfqdn),
cpu_usage = self.guarded(psutil.cpu_percent, 0),
ram_usage = self.guarded(psutil.virtual_memory),
swap_usage = self.guarded(psutil.swap_memory),
disk_usage = (disk_used, disk_total, disk_detail) if disk_total else None,
disk_io = self.guarded(psutil.disk_io_counters),
net_io = self.guarded(psutil.net_io_counters),
)
return data
[docs]class Router(object):
""" URL router middleware.
See http://docs.webob.org/en/latest/do-it-yourself.html
"""
ROUTES_RE = re.compile(r'''
\{ # The exact character "{"
(\w+) # The variable name (restricted to a-z, 0-9, _)
(?::([^}]+))? # The optional :regex part
\} # The exact character "}"
''', re.VERBOSE)
[docs] @classmethod
def parse_route(cls, template):
""" Parse a route definition, and return the compiled regex that matches it.
"""
regex = ''
last_pos = 0
for match in cls.ROUTES_RE.finditer(template):
regex += re.escape(template[last_pos:match.start()])
var_name = match.group(1)
expr = match.group(2) or '[^/]+'
expr = '(?P<%s>%s)' % (var_name, expr)
regex += expr
last_pos = match.end()
regex += re.escape(template[last_pos:])
regex = '^%s$' % regex
return re.compile(regex)
def __init__(self):
self.LOG = pymagic.get_class_logger(self)
self.routes = []
[docs] def add_route(self, template, controller, **kwargs):
""" Add a route definition
`controller` can be either a controller instance,
or the name of a callable that will be imported.
"""
if isinstance(controller, basestring):
controller = pymagic.import_name(controller)
self.routes.append((self.parse_route(template), controller, kwargs))
return self
def __call__(self, environ, start_response):
req = Request(environ)
self.LOG.debug("Incoming request at %r" % (req.path_info,))
for regex, controller, kwargs in self.routes:
match = regex.match(req.path_info)
if match:
req.urlvars = Bunch(kwargs)
req.urlvars.update(match.groupdict())
self.LOG.debug("controller=%r; vars=%r; req=%r; env=%r" % (controller, req.urlvars, req, environ))
return controller(environ, start_response)
return exc.HTTPNotFound()(environ, start_response)
@wsgify
def redirect(req, _log=pymagic.get_lazy_logger("redirect")):
""" Redirect controller to emit a HTTP 301.
"""
log = req.environ.get("wsgilog.logger", _log)
target = req.relative_url(req.urlvars.to)
log.info("Redirecting '%s' to '%s'" % (req.url, target))
return exc.HTTPMovedPermanently(location=target)
[docs]def make_app(httpd_config):
""" Factory for the monitoring webapp.
"""
#mimetypes.add_type('image/vnd.microsoft.icon', '.ico')
# Default paths to serve static file from
htdocs_paths = [
os.path.realpath(os.path.join(config.config_dir, "htdocs")),
os.path.join(os.path.dirname(config.__file__), "data", "htdocs"),
]
return (Router()
.add_route("/", controller=redirect, to="/static/index.html")
.add_route("/favicon.ico", controller=redirect, to="/static/favicon.ico")
.add_route("/static/{filepath:.+}", controller=StaticFolders(htdocs_paths))
.add_route("/json/{action}", controller=JsonController(**httpd_config.json))
)
[docs]def module_test():
""" Quick test using…
python -m pyrocore.daemon.webapp
"""
import pprint
from pyrocore import connect
try:
engine = connect()
print("%s - %s" % (engine.engine_id, engine.open()))
pprint.pprint(stats.engine_data(engine))
print("%s - %s" % (engine.engine_id, engine.open()))
except (error.LoggableError, xmlrpc.ERRORS) as torrent_exc:
print("ERROR: %s" % torrent_exc)
if __name__ == "__main__":
module_test()