From 51a12099e4f4f333a2275bbdc670b95f9639a53f Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Wed, 20 Apr 2016 21:25:19 -0700 Subject: [PATCH 001/132] Initial implementation of login control --- data/interfaces/default/base.html | 72 ++++---- data/interfaces/default/css/plexpy.css | 66 ++++++++ data/interfaces/default/graphs.html | 38 ++--- data/interfaces/default/history.html | 18 +- .../default/history_table_modal.html | 2 +- data/interfaces/default/home_stats.html | 40 ++--- data/interfaces/default/index.html | 2 +- data/interfaces/default/info.html | 28 ++-- data/interfaces/default/js/script.js | 58 +++---- .../interfaces/default/js/tables/libraries.js | 2 +- data/interfaces/default/js/tables/users.js | 2 +- data/interfaces/default/libraries.html | 18 +- data/interfaces/default/library.html | 20 +-- data/interfaces/default/login.html | 66 ++++++++ data/interfaces/default/logs.html | 18 +- data/interfaces/default/settings.html | 6 +- data/interfaces/default/sync.html | 16 +- data/interfaces/default/user.html | 22 +-- data/interfaces/default/users.html | 18 +- data/interfaces/default/welcome.html | 26 +-- plexpy/__init__.py | 2 + plexpy/webauth.py | 158 ++++++++++++++++++ plexpy/webserve.py | 14 +- plexpy/webstart.py | 53 +++--- 24 files changed, 541 insertions(+), 224 deletions(-) create mode 100644 data/interfaces/default/login.html create mode 100644 plexpy/webauth.py diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index e32ba5d3..7dc07bf0 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -12,14 +12,14 @@ from plexpy.helpers import anon_url - - + + - + ${next.headIncludes()} - - + + @@ -33,99 +33,99 @@ from plexpy.helpers import anon_url - + - + - + - + - + - + - + - + - + - - + + - - + + - - + + - - + + - - - - - - - - - - @@ -158,7 +158,7 @@ from plexpy.helpers import anon_url - PlexPy + PlexPy - - - + + + - - - - - + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - + + + + + + + % if len(top_stat['rows']) > 1: @@ -672,7 +672,7 @@ DOCUMENTATION :: END
@@ -725,7 +725,7 @@ DOCUMENTATION :: END
% else:
-
+
% endif @@ -771,7 +771,7 @@ DOCUMENTATION :: END % else:
-
+
% endif @@ -807,7 +807,7 @@ DOCUMENTATION :: END
-
+
% if len(top_stat['rows']) > 1:
@@ -839,7 +839,7 @@ DOCUMENTATION :: END
-
+
% endif diff --git a/data/interfaces/default/index.html b/data/interfaces/default/index.html index a430bfcc..59022365 100644 --- a/data/interfaces/default/index.html +++ b/data/interfaces/default/index.html @@ -65,7 +65,7 @@ <%def name="javascriptIncludes()"> - + - - - - - + + + + + + % if data: - + % if data['media_type'] == 'show' or data['media_type'] == 'artist': - - - - - + + + + + + - - - + + + + % if data: - - - + + + + + + + + \ No newline at end of file diff --git a/data/interfaces/default/logs.html b/data/interfaces/default/logs.html index 9bafbaab..d9304bbf 100644 --- a/data/interfaces/default/logs.html +++ b/data/interfaces/default/logs.html @@ -4,8 +4,8 @@ from plexpy import helpers %> <%def name="headIncludes()"> - - + + @@ -107,13 +107,13 @@ from plexpy import helpers <%def name="javascriptIncludes()"> - - - - - - - + + + + + + + - - + + + - - - - + + + + + - - - + + + + % if data: - - - - + + + + - - - - - + + + + + + - - - - + + + + + - - - \ No newline at end of file diff --git a/plexpy/webauth.py b/plexpy/webauth.py index 80066a51..e2ffdaed 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -128,7 +128,7 @@ class AuthController(object): username = escape(username, True) - return serve_template(templatename="login.html", title="Welcome", username=username, msg=msg) + return serve_template(templatename="login.html", title="Login", username=username, msg=msg) @cherrypy.expose def login(self, username=None, password=None, remember_me=0): @@ -144,7 +144,7 @@ class AuthController(object): cherrypy.session.regenerate() cherrypy.session[SESSION_KEY] = cherrypy.request.login = username self.on_login(username) - raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT or "/") + raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) @cherrypy.expose def logout(self): From 6f97173b004ae0df1d884ed9d561f88946f41f44 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Wed, 20 Apr 2016 22:25:33 -0700 Subject: [PATCH 003/132] Add http_root to settings page --- data/interfaces/default/settings.html | 11 ++++++++++- plexpy/config.py | 8 ++++++-- plexpy/webserve.py | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 502633fe..a222b36b 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -379,11 +379,20 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

Port to bind web server to. Note that ports below 1024 may require root.

+
+ +
+
+ +
+
+

The base URL for the web server. Used for reverse proxies.

+
-

Launch browser pointed to PlexPy, on startup.

+

Launch browser pointed to PlexPy on startup.

Launch browser pointed to PlexPy on startup.

@@ -469,9 +469,18 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
+

Password for web server authentication. Leave empty to disable.

+
+ +

Store a hashed password in the config.ini file.
Warning: Your password cannot be recovered if forgotten!

+
+
@@ -1957,6 +1966,7 @@ $(document).ready(function() { if ((serverChanged && $('#monitoring_use_websocket').is(":checked")) || authChanged || httpChanged || monitorChanged || directoryChanged) { $('#restart-modal').modal('show'); } + $("#http_hashed_password").val($("#http_hash_password").is(":checked") ? 1 : 0) } var configForm = $("#configUpdate"); @@ -2401,6 +2411,19 @@ $(document).ready(function() { }); }); + $("#http_hash_password").click(function(){ + if (!($("#http_hash_password").is(":checked")) && $("#http_hashed_password").val() == "1" && $("#http_password").val() == " ") { + $("#http_hashed_password").val(-1); + } else if ($("#http_hash_password").is(":checked") && $("#http_hashed_password").val() == "-1" && $("#http_password").val() == " ") { + $("#http_hashed_password").val(1); + $("#http_hash_password_error").html(""); + } + }); + + $('#http_password').change(function () { + $("#http_hashed_password").val($("#http_hash_password").is(":checked") ? 1 : 0); + $("#http_hash_password_error").html(""); + }); }); \ No newline at end of file diff --git a/lib/hashing_passwords.py b/lib/hashing_passwords.py new file mode 100644 index 00000000..1c2c963b --- /dev/null +++ b/lib/hashing_passwords.py @@ -0,0 +1,66 @@ +# coding: utf8 +""" + + Securely hash and check passwords using PBKDF2. + + Use random salts to protect againt rainbow tables, many iterations against + brute-force, and constant-time comparaison againt timing attacks. + + Keep parameters to the algorithm together with the hash so that we can + change the parameters and keep older hashes working. + + See more details at http://exyr.org/2011/hashing-passwords/ + + Author: Simon Sapin + License: BSD + +""" + +import hashlib +from os import urandom +from base64 import b64encode, b64decode +from itertools import izip + +# From https://github.com/mitsuhiko/python-pbkdf2 +from pbkdf2 import pbkdf2_bin + + +# Parameters to PBKDF2. Only affect new passwords. +SALT_LENGTH = 16 +KEY_LENGTH = 24 +HASH_FUNCTION = 'sha256' # Must be in hashlib. +# Linear to the hashing time. Adjust to be high but take a reasonable +# amount of time on your server. Measure with: +# python -m timeit -s 'import passwords as p' 'p.make_hash("something")' +COST_FACTOR = 29000 + + +def make_hash(password): + """Generate a random salt and return a new hash for the password.""" + if isinstance(password, unicode): + password = password.encode('utf-8') + salt = b64encode(urandom(SALT_LENGTH)) + return 'PBKDF2${}${}${}${}'.format( + HASH_FUNCTION, + COST_FACTOR, + salt, + b64encode(pbkdf2_bin(password, salt, COST_FACTOR, KEY_LENGTH, + getattr(hashlib, HASH_FUNCTION)))) + + +def check_hash(password, hash_): + """Check a password against an existing hash.""" + if isinstance(password, unicode): + password = password.encode('utf-8') + algorithm, hash_function, cost_factor, salt, hash_a = hash_.split('$') + assert algorithm == 'PBKDF2' + hash_a = b64decode(hash_a) + hash_b = pbkdf2_bin(password, salt, int(cost_factor), len(hash_a), + getattr(hashlib, hash_function)) + assert len(hash_a) == len(hash_b) # we requested this from pbkdf2_bin() + # Same as "return hash_a == hash_b" but takes a constant time. + # See http://carlos.bueno.org/2011/10/timing.html + diff = 0 + for char_a, char_b in izip(hash_a, hash_b): + diff |= ord(char_a) ^ ord(char_b) + return diff == 0 \ No newline at end of file diff --git a/lib/pbkdf2.py b/lib/pbkdf2.py new file mode 100644 index 00000000..b7a7dd42 --- /dev/null +++ b/lib/pbkdf2.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +""" + pbkdf2 + ~~~~~~ + + This module implements pbkdf2 for Python. It also has some basic + tests that ensure that it works. The implementation is straightforward + and uses stdlib only stuff and can be easily be copy/pasted into + your favourite application. + + Use this as replacement for bcrypt that does not need a c implementation + of a modified blowfish crypto algo. + + Example usage: + + >>> pbkdf2_hex('what i want to hash', 'the random salt') + 'fa7cc8a2b0a932f8e6ea42f9787e9d36e592e0c222ada6a9' + + How to use this: + + 1. Use a constant time string compare function to compare the stored hash + with the one you're generating:: + + def safe_str_cmp(a, b): + if len(a) != len(b): + return False + rv = 0 + for x, y in izip(a, b): + rv |= ord(x) ^ ord(y) + return rv == 0 + + 2. Use `os.urandom` to generate a proper salt of at least 8 byte. + Use a unique salt per hashed password. + + 3. Store ``algorithm$salt:costfactor$hash`` in the database so that + you can upgrade later easily to a different algorithm if you need + one. For instance ``PBKDF2-256$thesalt:10000$deadbeef...``. + + + :copyright: (c) Copyright 2011 by Armin Ronacher. + :license: BSD, see LICENSE for more details. +""" +import hmac +import hashlib +from struct import Struct +from operator import xor +from itertools import izip, starmap + + +_pack_int = Struct('>I').pack + + +def pbkdf2_hex(data, salt, iterations=1000, keylen=24, hashfunc=None): + """Like :func:`pbkdf2_bin` but returns a hex encoded string.""" + return pbkdf2_bin(data, salt, iterations, keylen, hashfunc).encode('hex') + + +def pbkdf2_bin(data, salt, iterations=1000, keylen=24, hashfunc=None): + """Returns a binary digest for the PBKDF2 hash algorithm of `data` + with the given `salt`. It iterates `iterations` time and produces a + key of `keylen` bytes. By default SHA-1 is used as hash function, + a different hashlib `hashfunc` can be provided. + """ + hashfunc = hashfunc or hashlib.sha1 + mac = hmac.new(data, None, hashfunc) + def _pseudorandom(x, mac=mac): + h = mac.copy() + h.update(x) + return map(ord, h.digest()) + buf = [] + for block in xrange(1, -(-keylen // mac.digest_size) + 1): + rv = u = _pseudorandom(salt + _pack_int(block)) + for i in xrange(iterations - 1): + u = _pseudorandom(''.join(map(chr, u))) + rv = starmap(xor, izip(rv, u)) + buf.extend(rv) + return ''.join(map(chr, buf))[:keylen] + + +def test(): + failed = [] + def check(data, salt, iterations, keylen, expected): + rv = pbkdf2_hex(data, salt, iterations, keylen) + if rv != expected: + print 'Test failed:' + print ' Expected: %s' % expected + print ' Got: %s' % rv + print ' Parameters:' + print ' data=%s' % data + print ' salt=%s' % salt + print ' iterations=%d' % iterations + print + failed.append(1) + + # From RFC 6070 + check('password', 'salt', 1, 20, + '0c60c80f961f0e71f3a9b524af6012062fe037a6') + check('password', 'salt', 2, 20, + 'ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957') + check('password', 'salt', 4096, 20, + '4b007901b765489abead49d926f721d065a429c1') + check('passwordPASSWORDpassword', 'saltSALTsaltSALTsaltSALTsaltSALTsalt', + 4096, 25, '3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038') + check('pass\x00word', 'sa\x00lt', 4096, 16, + '56fa6aa75548099dcc37d7f03425e0c3') + # This one is from the RFC but it just takes for ages + ##check('password', 'salt', 16777216, 20, + ## 'eefe3d61cd4da4e4e9945b3d6ba2158c2634e984') + + # From Crypt-PBKDF2 + check('password', 'ATHENA.MIT.EDUraeburn', 1, 16, + 'cdedb5281bb2f801565a1122b2563515') + check('password', 'ATHENA.MIT.EDUraeburn', 1, 32, + 'cdedb5281bb2f801565a1122b25635150ad1f7a04bb9f3a333ecc0e2e1f70837') + check('password', 'ATHENA.MIT.EDUraeburn', 2, 16, + '01dbee7f4a9e243e988b62c73cda935d') + check('password', 'ATHENA.MIT.EDUraeburn', 2, 32, + '01dbee7f4a9e243e988b62c73cda935da05378b93244ec8f48a99e61ad799d86') + check('password', 'ATHENA.MIT.EDUraeburn', 1200, 32, + '5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13') + check('X' * 64, 'pass phrase equals block size', 1200, 32, + '139c30c0966bc32ba55fdbf212530ac9c5ec59f1a452f5cc9ad940fea0598ed1') + check('X' * 65, 'pass phrase exceeds block size', 1200, 32, + '9ccad6d468770cd51b10e6a68721be611a8b4d282601db3b36be9246915ec82a') + + raise SystemExit(bool(failed)) + + +if __name__ == '__main__': + test() diff --git a/plexpy/config.py b/plexpy/config.py index 8c1ce064..791beec3 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -144,6 +144,8 @@ _CONFIG_DEFINITIONS = { 'HTTPS_DOMAIN': (str, 'General', 'localhost'), 'HTTPS_IP': (str, 'General', '127.0.0.1'), 'HTTP_ENVIRONMENT': (str, 'General', 'production'), + 'HTTP_HASH_PASSWORD': (int, 'General', 0), + 'HTTP_HASHED_PASSWORD': (int, 'General', 0), 'HTTP_HOST': (str, 'General', '0.0.0.0'), 'HTTP_PASSWORD': (str, 'General', ''), 'HTTP_PORT': (int, 'General', 8181), diff --git a/plexpy/webauth.py b/plexpy/webauth.py index e2ffdaed..c9ab67c8 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -20,6 +20,7 @@ import cherrypy from cgi import escape +from hashing_passwords import check_hash import plexpy from plexpy import logger @@ -30,8 +31,10 @@ SESSION_KEY = '_cp_username' def check_credentials(username, password): """Verifies credentials for username and password. Returns None on success or a string describing the error on failure""" - # Adapt to your needs - if username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: + if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \ + username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD): + return None + elif username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: return None else: return u"Incorrect username or password." diff --git a/plexpy/webserve.py b/plexpy/webserve.py index b06960c9..f35d647b 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -20,6 +20,7 @@ from plexpy.webauth import AuthController, require, member_of, name_is from mako.lookup import TemplateLookup from mako import exceptions +from hashing_passwords import make_hash import plexpy import threading @@ -1192,6 +1193,8 @@ class WebInterface(object): http_password = '' config = { + "http_hash_password": checked(plexpy.CONFIG.HTTP_HASH_PASSWORD), + "http_hashed_password": plexpy.CONFIG.HTTP_HASHED_PASSWORD, "http_host": plexpy.CONFIG.HTTP_HOST, "http_username": plexpy.CONFIG.HTTP_USERNAME, "http_port": plexpy.CONFIG.HTTP_PORT, @@ -1315,7 +1318,7 @@ class WebInterface(object): "ip_logging_enable", "movie_logging_enable", "tv_logging_enable", "music_logging_enable", "pms_is_remote", "home_stats_type", "group_history_tables", "notify_consecutive", "notify_upload_posters", "notify_recently_added", "notify_recently_added_grandparent", - "monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist" + "monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist", "http_hash_password" ] for checked_config in checked_configs: if checked_config not in kwargs: @@ -1327,7 +1330,20 @@ class WebInterface(object): # If http password exists in config, do not overwrite when blank value received if kwargs.get('http_password'): if kwargs['http_password'] == ' ' and plexpy.CONFIG.HTTP_PASSWORD != '': - kwargs['http_password'] = plexpy.CONFIG.HTTP_PASSWORD + if kwargs.get('http_hash_password') and not plexpy.CONFIG.HTTP_HASHED_PASSWORD: + kwargs['http_password'] = make_hash(plexpy.CONFIG.HTTP_PASSWORD) + kwargs['http_hashed_password'] = 1 + else: + kwargs['http_password'] = plexpy.CONFIG.HTTP_PASSWORD + + elif kwargs['http_password'] and kwargs.get('http_hash_password'): + kwargs['http_password'] = make_hash(kwargs['http_password']) + kwargs['http_hashed_password'] = 1 + + elif not kwargs.get('http_hash_password'): + kwargs['http_hashed_password'] = 0 + else: + kwargs['http_hashed_password'] = 0 for plain_config, use_config in [(x[4:], x) for x in kwargs if x.startswith('use_')]: # the use prefix is fairly nice in the html, but does not match the actual config From d8ad9adabd8fd1fbb510443316b26c0fb5587753 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 00:11:30 -0700 Subject: [PATCH 005/132] A bunch of UI updates --- data/interfaces/default/base.html | 71 ++++++-- data/interfaces/default/css/plexpy.css | 167 ++++++++++++------ .../default/current_activity_header.html | 4 +- data/interfaces/default/index.html | 27 ++- data/interfaces/default/info.html | 2 +- .../default/library_recently_added.html | 62 ++++--- data/interfaces/default/login.html | 4 +- data/interfaces/default/recently_added.html | 61 +++++-- data/interfaces/default/settings.html | 115 +++++++++--- .../default/user_recently_watched.html | 44 +++-- plexpy/config.py | 15 +- plexpy/libraries.py | 5 +- plexpy/users.py | 5 +- plexpy/webserve.py | 19 +- 14 files changed, 425 insertions(+), 176 deletions(-) diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index 7dc07bf0..9045eae7 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -14,7 +14,7 @@ from plexpy.helpers import anon_url - + ${next.headIncludes()} @@ -176,9 +176,9 @@ from plexpy.helpers import anon_url % if title == "Home": -
  • +
  • % else: -
  • +
  • % endif % if title == "Libraries" or title == "Library" or title == "Info":
  • Libraries
  • @@ -186,9 +186,9 @@ from plexpy.helpers import anon_url
  • Libraries
  • % endif % if title == "Users" or title == "User": -
  • Users
  • +
  • Users
  • % else: -
  • Users
  • +
  • Users
  • % endif % if title == "History":
  • History
  • @@ -196,25 +196,32 @@ from plexpy.helpers import anon_url
  • History
  • % endif % if title == "Graphs": -
  • Graphs
  • +
  • Graphs
  • % else: -
  • Graphs
  • +
  • Graphs
  • % endif % if title == "Synced Items": -
  • Synced Items
  • +
  • Synced Items
  • % else: -
  • Synced Items
  • - % endif - % if title == "Log": -
  • Logs
  • - % else: -
  • Logs
  • +
  • Synced Items
  • % endif % if title == "Settings": -
  • Settings
  • +
  • Settings
  • +
    @@ -239,6 +246,25 @@ ${next.headerIncludes()} if (!getCookie('updateDismiss')) { $('#updatebar').show(); } + + $("#nav-shutdown").click(function () { + var r = confirm("Are you sure you want to shutdown PlexPy?"); + if (r == true) { + window.location.href = "shutdown"; + } + }); + + $("#nav-restart").click(function () { + window.location.href = "restart"; + }); + + $("#nav-update").first().one("click", function () { + // Allow the update bar to show again if previously dismissed. + setCookie('updateDismiss', 'true', 0); + $(this).html(' Checking'); + window.location.href = "checkGithub"; + }); + ${next.javascriptIncludes()} diff --git a/data/interfaces/default/css/plexpy.css b/data/interfaces/default/css/plexpy.css index 04e00d54..6738fb88 100644 --- a/data/interfaces/default/css/plexpy.css +++ b/data/interfaces/default/css/plexpy.css @@ -1,5 +1,5 @@ body { - font-family: 'Open Sans', sans-serif; + font-family: 'Open Sans', Arial, sans-serif; color: #fff; margin-top: 50px; overflow: hidden; @@ -9,7 +9,7 @@ a { } a:hover, a:focus { - color: #f9aa03; + color: #e9a049; text-decoration: none; outline: none; } @@ -89,7 +89,7 @@ img { .nav > li.active > a, .nav > li.active > a:hover, .nav > li.active > a:focus { - color: #f9aa03; + color: #f9be03; background-color: #282828; } .navbar-toggle { @@ -99,6 +99,36 @@ img { .navbar-toggle:focus { background-color: #2f2f2f; } +@media screen and (min-width: 768px) { + .dropdown:hover > .dropdown-menu { + display: block; + } +} +.dropdown-menu { + background-color: #282828; +} +.dropdown-menu .divider { + background-color: #777; +} +.dropdown-menu > li > a { + color: #999; +} +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + color: #fff; + background-color: #2f2f2f; +} +.dropdown-menu > .active > a, +.dropdown-menu > .active > a:hover, +.dropdown-menu > .active > a:focus { + color: #fff; + background-color: #2f2f2f; +} +.dropdown-menu > .disabled > a, +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + color: #999; +} .icon-bar { background-color: #999; } @@ -109,7 +139,13 @@ img { color: #eee; } .padded-header h3 { - font-size: 20px; + font-size: 16px; + font-weight: bold; + text-transform: uppercase; +} +.padded-header h3 small { + font-size: 13px; + text-transform: none; } .btn { outline:0px !important; @@ -182,9 +218,9 @@ fieldset[disabled] .btn-dark.active { background-color: #3B3B3B; } .btn-bright { - color: #fff; - background-color: #eb8600; - border-color: transparent; + color: #fff; + background-color: #cc7b19; + box-shadow: inset 0 1px 0 #e7993b; } .btn-bright:focus, .btn-bright.focus { @@ -193,14 +229,15 @@ fieldset[disabled] .btn-dark.active { } .btn-bright:hover { color: #fff; - background-color: #E69400; - border-color: #f9aa03; + background-color: #e59029; + box-shadow: inset 0 1px 0 #ebac60; } .btn-bright:active, .btn-bright.active, .open > .dropdown-toggle.btn-bright { color: #fff; - background-color: #eb8600; + background-color: #cc7b19; + box-shadow: inset 0 1px 0 #e7993b; } .btn-bright:active:hover, .btn-bright.active:hover, @@ -212,7 +249,8 @@ fieldset[disabled] .btn-dark.active { .btn-bright.active.focus, .open > .dropdown-toggle.btn-bright.focus { color: #fff; - background-color: #eb8600; + background-color: #cc7b19; + box-shadow: inset 0 1px 0 #e7993b; } .btn-bright:active, .btn-bright.active, @@ -237,12 +275,13 @@ fieldset[disabled] .btn-bright:active, .btn-bright.disabled.active, .btn-bright[disabled].active, fieldset[disabled] .btn-bright.active { - background-color: #c9302c; - border-color: #ac2925; + background-color: #cc7b19; + border-color: #b56d16; } .btn-bright .badge { color: #fff; - background-color: #eb8600; + background-color: #cc7b19; + box-shadow: inset 0 1px 0 #e7993b; } .btn-danger.btn-edit { color: #d7d7d7; @@ -377,7 +416,7 @@ textarea.form-control:focus { .pagination > li > span:hover, .pagination > li > a:focus, .pagination > li > span:focus { - background-color: #F9AA03; + background-color: #cc7b19; border: 1px solid #444444; } .pagination > .active > a, @@ -389,7 +428,7 @@ textarea.form-control:focus { z-index: 2; color: #fff; cursor: default; - background-color: #F9AA03; + background-color: #cc7b19; border-color: #444444; } .pagination > .disabled > span, @@ -407,7 +446,7 @@ textarea.form-control:focus { .nav-pills > li.active > a:hover, .nav-pills > li.active > a:focus { color: #fff; - background-color: #af6c17; + background-color: #cc7b19; } .nav-pills > li > a { border-radius: 3px; @@ -871,16 +910,16 @@ a .dashboard-activity-metadata-user-thumb:hover { color: #999; } .dashboard-activity-metadata-user a:hover { - color: #F9AA03; + color: #e9a049; } .dashboard-activity-metadata-title a:hover { - color: #F9AA03; + color: #e9a049; } .dashboard-activity-metadata-progress-wrapper { margin-bottom: 20px; font-size: 12px; font-weight: bold; - color: #F9AA03; + color: #e9a049; } .dashboard-recent-media-row { width: 100%; @@ -957,6 +996,7 @@ a:hover .dashboard-recent-media-cover { .dashboard-recent-media-overlay-text { color: #aaa; font-size: 12px; + font-weight: bold; float: left; position: absolute; left: 8px; @@ -974,9 +1014,9 @@ a:hover .dashboard-recent-media-cover { overflow: hidden; position: relative; font-size: 13px; + font-weight: bold; margin: 0; line-height: 15px; - font-weight: normal; width: 150px; white-space: nowrap; text-align: left; @@ -985,13 +1025,11 @@ a:hover .dashboard-recent-media-cover { .dashboard-recent-media-metacontainer h3.text-muted { color: #777; } -.dashboard-recent-media-metacontainer .text-muted { - padding: 5px 3px 0 3px; - text-overflow: ellipsis; - overflow: hidden; - position: relative; - white-space: nowrap; - text-align: left; +.dashboard-recent-media-metacontainer h3.text-muted a { + color: #777; +} +.dashboard-recent-media-metacontainer h3.text-muted a:hover { + color: #e9a049; } .art-face { background-repeat: no-repeat; @@ -1057,7 +1095,7 @@ a:hover .dashboard-recent-media-cover { color: #999; } .summary-navbar-list .breadcrumb a:hover { - color: #F9AA03; + color: #f9be03; } .summary-content-title-wrapper { height: 150px; @@ -1071,7 +1109,7 @@ a:hover .dashboard-recent-media-cover { .summary-content-title h1 { margin-top: 0; margin-bottom: 10px; - color: #F9AA03; + color: #f9be03; font-size: 28px; line-height: 40px; float: left; @@ -1082,11 +1120,10 @@ a:hover .dashboard-recent-media-cover { width: 100%; } .summary-content-title h1 a { - color: #F9AA03; + color: #f9be03; } .summary-content-title h1 a:hover { - color: #F9AA03; - text-decoration: underline; + color: #fff; } .summary-content-title h2 { margin-top: 0; @@ -1335,7 +1372,7 @@ a:hover .summary-poster-face-track .summary-poster-face-overlay span { .star-rating .star-icon { width: auto; margin-left: 2px; - color: #F9AA03; + color: #f9be03; } .star-rating .star-icon-o { width: auto; @@ -1438,6 +1475,7 @@ a:hover .item-children-poster { .item-children-overlay-text { color: #aaa; font-size: 12px; + font-weight: bold; float: left; position: absolute; left: 8px; @@ -1465,9 +1503,9 @@ a:hover .item-children-poster { overflow: hidden; position: relative; font-size: 13px; + font-weight: bold; margin: 0; line-height: 15px; - font-weight: normal; white-space: nowrap; text-align: left; clear: both; @@ -1475,6 +1513,12 @@ a:hover .item-children-poster { .item-children-instance-text-wrapper h3.text-muted { color: #777; } +.item-children-instance-text-wrapper h3.text-muted a { + color: #777; +} +.item-children-instance-text-wrapper h3.text-muted a:hover { + color: #e9a049; +} .item-children-list-item-odd { border-top: 0px solid #343434; border-bottom: 0px solid #343434; @@ -1519,7 +1563,7 @@ a:hover .item-children-poster { margin-right: 20px; } #new_title h3 { - color: #F9AA03; + color: #f9be03; font-size: 14px; line-height: 1.42857143; font-weight: bold; @@ -1582,10 +1626,15 @@ a:hover .item-children-poster { top: 15px; left: -5px; } -.user-info-nav > .active > a, .nav-tabs > .active > a:hover, .nav-tabs > .active > a:focus { - color: #F9AA03; +.user-info-nav > .active > a { + color: #cc7b19; +} +.nav-tabs > .active > a:hover, +.nav-tabs > .active > a:focus { + color: #e9a049; } .user-info-nav a:hover { + color: #e9a049; text-decoration: none; } .user-info-nav ul { @@ -1640,7 +1689,7 @@ a:hover .item-children-poster { .user-overview-stats-instance h3 { font-size: 30px; font-weight: bold; - color: #F9AA03; + color: #f9be03; line-height: 22px; position: relative; top: 5px; @@ -1692,8 +1741,8 @@ a:hover .item-children-poster { text-overflow: ellipsis; overflow: hidden; position: relative; - font-size: 13px; - line-height: 15px; + font-size: 14px; + line-height: 16px; font-weight: normal; width: 140px; margin-left: 10px; @@ -1702,7 +1751,7 @@ a:hover .item-children-poster { .user-player-instance-playcount h3 { font-size: 30px; font-weight: bold; - color: #F9AA03; + color: #f9be03; line-height: 22px; position: relative; top: 5px; @@ -1784,8 +1833,8 @@ a:hover .item-children-poster { text-overflow: ellipsis; overflow: hidden; position: relative; - font-size: 13px; - line-height: 15px; + font-size: 14px; + line-height: 16px; font-weight: bold; width: 100%; padding: 0 0 0 20px; @@ -1810,7 +1859,7 @@ a:hover .item-children-poster { .home-platforms-instance-playcount h3 { font-size: 30px; font-weight: bold; - color: #F9AA03; + color: #f9be03; line-height: 22px; position: relative; top: 5px; @@ -1919,7 +1968,7 @@ a:hover .item-children-poster { height: 60px; } .home-platforms-instance-list-number { - background-color: #eb8600; + background-color: #f9be03; float: left; position: absolute; top: -10px; @@ -1976,7 +2025,7 @@ a:hover .item-children-poster { .home-platforms-instance-list-playcount h3 { font-size: 20px; font-weight: bold; - color: #F9AA03; + color: #f9be03; line-height: 22px; position: relative; margin: 0 5px 0 0; @@ -2071,10 +2120,10 @@ a:hover .item-children-poster { transition: all 0.3s ease; } .home-platforms-instance-list-chevron i:hover { - color: #eb8600; + color: #f9be03; } .home-platforms-instance-list-chevron.active i.fa-chevron-down{ - color: #eb8600; + color: #f9be03; -webkit-transform: rotate(180deg); -ms-transform: rotate(180deg); -o-transform: rotate(180deg); @@ -2173,6 +2222,7 @@ a .home-platforms-instance-list-oval:hover, } .header-bar span { font-size: 22px; + font-weight: bold; line-height: 34px; } .button-bar { @@ -2200,6 +2250,7 @@ a .home-platforms-instance-list-oval:hover, border-top: 1px solid #2d2d2d; } .nav-settings > li > a { + border-bottom: 1px solid #232323; display: block; padding: 15px 15px 15px 15px; color: #999; @@ -2215,7 +2266,7 @@ a .home-platforms-instance-list-oval:hover, .nav-settings > .active > a, .nav-settings > .active > a:hover, .nav-settings > .active > a:focus { - color: #eb8600; + color: #f9be03; background-color: #2f2f2f; } .stacked-configs, @@ -2257,7 +2308,7 @@ a .home-platforms-instance-list-oval:hover, color: #eee; } .stacked-configs > li > span > a.active { - color: #eb8600; + color: #f9be03; } .accordion { width: 100%; @@ -2299,10 +2350,10 @@ a .home-platforms-instance-list-oval:hover, background: #2f2f2f; } .accordion li.open .link { - color: #eb8600; + color: #f9be03; } .accordion li.open i { - color: #eb8600; + color: #f9be03; } .accordion li.open i.fa-chevron-down { -webkit-transform: rotate(180deg); @@ -2330,7 +2381,7 @@ a .home-platforms-instance-list-oval:hover, transition: all 0.25s ease; } .submenu a:hover { - background: #eb8600; + background: #f9be03; color: #FFF; } .ajaxMsg { @@ -2532,7 +2583,7 @@ a .home-platforms-instance-list-oval:hover, margin-right: 3px; } #updatebar a:hover { - color: #F9AA03; + color: #e9a049; } .body-container { position: absolute; @@ -2593,13 +2644,13 @@ table.display tr.shown + tr .pagination > .active > a:hover { table.display tr.shown + tr table[id^='history_child'] td:hover a, table.display tr.shown + tr table[id^='media_info_child'] > tr > td:hover a, table.display tr.shown + tr table[id^='media_info_child'] tr.shown + tr table[id^='media_info_child'] td:hover a { - color: #F9AA03; + color: #cc7b19; } table.display tr.shown + tr .pagination > .disabled > a { color: #444444; } table.display tr.shown + tr .pagination > li > a:hover { - color: #23527c; + color: #e9a049; } table[id^='history_child'] { margin-top: 0; @@ -2791,6 +2842,8 @@ a.no-highlight:hover { #recently-added-row-scroller, #recently-watched-row-scroller { position: relative; + height: 265px; + margin-bottom: 25px; } @media (min-width: 768px) { diff --git a/data/interfaces/default/current_activity_header.html b/data/interfaces/default/current_activity_header.html index 04d8f65b..30fc6c8d 100644 --- a/data/interfaces/default/current_activity_header.html +++ b/data/interfaces/default/current_activity_header.html @@ -18,9 +18,9 @@ DOCUMENTATION :: END % if data == '0':

    Activity

    % elif data == '1': -

    Activity ${data} stream

    +

    Activity   ${data} stream

    % else: -

    Activity ${data} streams

    +

    Activity   ${data} streams

    % endif % else:

    Activity

    diff --git a/data/interfaces/default/index.html b/data/interfaces/default/index.html index 59022365..af584bad 100644 --- a/data/interfaces/default/index.html +++ b/data/interfaces/default/index.html @@ -5,6 +5,8 @@ <%def name="body()">
    + % for section in config['home_sections']: + % if section == 'current_activity':
    @@ -16,11 +18,11 @@
    - % if config['home_stats_cards']: + % elif section == 'watch_stats':
    -

    Watch Statistics Last ${config['home_stats_length']} days

    +

    Watch Statistics   Last ${config['home_stats_length']} days

    Loading stats...
    @@ -28,12 +30,11 @@
    - % endif - % if config['home_library_cards']: + % elif section == 'library_stats':
    -

    Library Statistics ${config['pms_name']}

    +

    Library Statistics   ${config['pms_name']}

    Loading stats...
    @@ -41,7 +42,7 @@
    - % endif + % elif section == 'recently_added':
    @@ -53,7 +54,10 @@ -

    Recently Added

    +

    Recently Added    + Movies   + TV Shows   + Music

    Looking for new items...
    @@ -61,6 +65,8 @@
    + % endif + % endfor
    @@ -186,6 +192,13 @@ $("#recently-added-page-right").removeClass("disabled"); } }); + + $('.toggle-recently-added-type').click(function () { + var media_type = $(this).data('type'); + var margin_right = $(this).hasClass('text-muted') ? '25px' : 0; + $('.dashboard-recent-media-instance li[data-type=' + media_type + ']').animate({ width: 'toggle', marginRight: margin_right }, 1000); + $(this).toggleClass('text-muted').blur(); + }); diff --git a/data/interfaces/default/info.html b/data/interfaces/default/info.html index ea865cea..37911ca2 100644 --- a/data/interfaces/default/info.html +++ b/data/interfaces/default/info.html @@ -119,7 +119,7 @@ DOCUMENTATION :: END % elif data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track': -
    +
    diff --git a/data/interfaces/default/library_recently_added.html b/data/interfaces/default/library_recently_added.html index 12bd495f..95c421d9 100644 --- a/data/interfaces/default/library_recently_added.html +++ b/data/interfaces/default/library_recently_added.html @@ -42,32 +42,40 @@ DOCUMENTATION :: END % if item['media_type'] == 'episode': % if item['parent_thumb']:
    - % else: -
    - % endif - % elif item['media_type'] == 'movie': -
    - % endif -
    -
    - -
    -
    + % else: +
    + % endif + % elif item['media_type'] == 'movie': +
    + % endif +
    +
    +
    -
    - % if item['media_type'] == 'episode': -

    ${item['grandparent_title']}

    -

    ${item['title']}

    -

    S${item['parent_media_index']} · E${item['media_index']}

    - % elif item['media_type'] == 'movie': -

    ${item['title']}

    -

    ${item['year']}

    - % endif -
    +
    +
    +
    + % if item['media_type'] == 'episode': +

    + ${item['grandparent_title']} +

    +

    + ${item['title']} +

    +

    + S${item['parent_media_index']} · E${item['media_index']} +

    + % elif item['media_type'] == 'movie': +

    + ${item['title']} +

    +

    ${item['year']}

    + % endif +
    % elif item['media_type'] == 'album':
    @@ -82,8 +90,12 @@ DOCUMENTATION :: END
    % endif diff --git a/data/interfaces/default/login.html b/data/interfaces/default/login.html index aa2745ad..05491036 100644 --- a/data/interfaces/default/login.html +++ b/data/interfaces/default/login.html @@ -9,6 +9,8 @@ + + @@ -40,7 +42,7 @@
    diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 7be5df0f..3a5b0033 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -20,7 +20,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
    - Settings + Settings
    % if config['check_github']: @@ -131,10 +131,15 @@ available_notification_agents = sorted(notifiers.available_notification_agents() Gitter Chat: https://gitter.im/drzoidberg33/plexpy + + Donations: + Paypal | + Bitcoin +
    -

    PlexPy Scheduler

    +

    PlexPy Scheduled Tasks

    Loading scheduler table...
    @@ -228,7 +233,49 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

    +
    +
    +

    Homepage

    +
    + + +

    + Select the sections to show on the homepage.
    + Drag the items below to reorder your homepage content. +

    +
    +
    +
      +
    • +
      + +
    • +
    • +
      + +
    • +
    • +
      + +
    • +
    • +
      + +
    • +
    + +
    +
    +

    Watch Statistics

    @@ -236,24 +283,12 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

    - Select the cards to show in the watch statistics on the home page. Select none to disable.
    + Select the cards to show in the watch statistics on the home page. Drag the items below to reorder your homepage content.

      -
    • -
      - -
    • -
    • -
      - -
    • +
    • +
      + +
    • +
    • +
      + +
    @@ -2002,15 +2049,11 @@ $(document).ready(function() { $("#menu_link_update_check").click(function() { // Allow the update bar to show again if previously dismissed. setCookie('updateDismiss', 'true', 0); - $(this).html(' Checking'); + $(this).html(' Checking'); $(this).prop('disabled', true); window.location.href = "checkGithub"; }); - $("#modal_link_restart").click(function() { - window.location.href = "restart"; - }); - if ($("#api_enabled").is(":checked")) { $("#apioptions").show(); } else { @@ -2301,6 +2344,31 @@ $(document).ready(function() { var accordion_timeline = new Accordion($('#accordion-timeline'), false); var accordion_scripts = new Accordion($('#accordion-scripts'), false); + // Sortable home_sections + function set_home_sections() { + var home_sections = []; + var hsecs = $('[id^=hsec-]').serializeArray(); + $.each(hsecs, function(i, sec) { + home_sections.push(sec.value); + }); + $('#home_sections').val(home_sections); + }; + + var sec_cards = ${config['home_sections'] | n}; + sec_cards.reverse().forEach(function (item) { + $('#hsec-' + item).prop('checked', !$(this).prop('checked')) + $('#hsec-' + item).closest('li.card').prependTo('#sortable_home_sections'); + }); + + Sortable.create(sortable_home_sections, { + animation: 250, + onSort: function(elem, ui) { + set_home_sections(); + } + }); + + $('[id^=hsec-]').change(function() { set_home_sections(); }); + set_home_sections() // Sortable home_stats_cards function set_home_stats_cards() { @@ -2328,7 +2396,6 @@ $(document).ready(function() { $('[id^=hscard-]').change(function() { set_home_stats_cards(); }); set_home_stats_cards() - // Sortable home_library_cards function set_home_library_cards() { var home_library_cards = []; diff --git a/data/interfaces/default/user_recently_watched.html b/data/interfaces/default/user_recently_watched.html index 23734bcf..ab634ac8 100644 --- a/data/interfaces/default/user_recently_watched.html +++ b/data/interfaces/default/user_recently_watched.html @@ -45,17 +45,25 @@ DOCUMENTATION :: END
    -
    - % if item['media_type'] == 'episode': -

    ${item['grandparent_title']}

    -

    ${item['title']}

    -

    S${item['parent_media_index']} · E${item['media_index']}

    - % elif item['media_type'] == 'movie': -

    ${item['title']}

    -

    ${item['year']}

    - % endif -
    +
    + % if item['media_type'] == 'episode': +

    + ${item['grandparent_title']} +

    +

    + ${item['title']} +

    +

    + S${item['parent_media_index']} · E${item['media_index']} +

    + % elif item['media_type'] == 'movie': +

    + ${item['title']} +

    +

    ${item['year']}

    + % endif +
    % elif item['media_type'] == 'track':
    @@ -69,12 +77,18 @@ DOCUMENTATION :: END
    -
    -

    ${item['grandparent_title']}

    -

    ${item['title']}

    -

    ${item['parent_title']}

    -
    + % endif % endfor diff --git a/plexpy/config.py b/plexpy/config.py index 791beec3..453dcb6b 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -132,11 +132,12 @@ _CONFIG_DEFINITIONS = { 'GROWL_ON_EXTUP': (int, 'Growl', 0), 'GROWL_ON_INTUP': (int, 'Growl', 0), 'GROWL_ON_PMSUPDATE': (int, 'Growl', 0), + 'HOME_SECTIONS': (list, 'General', ['current_activity','watch_stats','library_stats','recently_added']), 'HOME_LIBRARY_CARDS': (list, 'General', ['first_run']), 'HOME_STATS_LENGTH': (int, 'General', 30), 'HOME_STATS_TYPE': (int, 'General', 0), 'HOME_STATS_COUNT': (int, 'General', 5), - 'HOME_STATS_CARDS': (list, 'General', ['top_tv', 'popular_tv', 'top_movies', 'popular_movies', 'top_music', \ + 'HOME_STATS_CARDS': (list, 'General', ['top_movies', 'popular_movies', 'top_tv', 'popular_tv', 'top_music', \ 'popular_music', 'last_watched', 'top_users', 'top_platforms', 'most_concurrent']), 'HTTPS_CREATE_CERT': (int, 'General', 1), 'HTTPS_CERT': (str, 'General', ''), @@ -604,4 +605,16 @@ class Config(object): if self.CONFIG_VERSION == '3': if self.HTTP_ROOT == '/': self.HTTP_ROOT = '' + self.CONFIG_VERSION = '4' + + if self.CONFIG_VERSION == '4': + print not len(self.HOME_STATS_CARDS) and 'watch_stats' in self.HOME_SECTIONS + if not len(self.HOME_STATS_CARDS) and 'watch_stats' in self.HOME_SECTIONS: + home_sections = self.HOME_SECTIONS + home_sections.remove('watch_stats') + self.HOME_SECTIONS = home_sections + if not len(self.HOME_LIBRARY_CARDS) and 'library_stats' in self.HOME_SECTIONS: + home_sections = self.HOME_SECTIONS + home_sections.remove('library_stats') + self.HOME_SECTIONS = home_sections self.CONFIG_VERSION = '4' \ No newline at end of file diff --git a/plexpy/libraries.py b/plexpy/libraries.py index 455ca7a5..cb5fbf60 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -711,7 +711,8 @@ class Libraries(object): try: if str(section_id).isdigit(): - query = 'SELECT session_history.id, session_history.media_type, session_history.rating_key, session_history.parent_rating_key, ' \ + query = 'SELECT session_history.id, session_history.media_type, ' \ + 'session_history.rating_key, session_history.parent_rating_key, session_history.grandparent_rating_key, ' \ 'title, parent_title, grandparent_title, thumb, parent_thumb, grandparent_thumb, media_index, parent_media_index, ' \ 'year, started, user ' \ 'FROM session_history_metadata ' \ @@ -738,6 +739,8 @@ class Libraries(object): recent_output = {'row_id': row['id'], 'media_type': row['media_type'], 'rating_key': row['rating_key'], + 'parent_rating_key': row['parent_rating_key'], + 'grandparent_rating_key': row['grandparent_rating_key'], 'title': row['title'], 'parent_title': row['parent_title'], 'grandparent_title': row['grandparent_title'], diff --git a/plexpy/users.py b/plexpy/users.py index 1fd4d637..012188d0 100644 --- a/plexpy/users.py +++ b/plexpy/users.py @@ -439,7 +439,8 @@ class Users(object): try: if str(user_id).isdigit(): - query = 'SELECT session_history.id, session_history.media_type, session_history.rating_key, session_history.parent_rating_key, ' \ + query = 'SELECT session_history.id, session_history.media_type, ' \ + 'session_history.rating_key, session_history.parent_rating_key, session_history.grandparent_rating_key, ' \ 'title, parent_title, grandparent_title, thumb, parent_thumb, grandparent_thumb, media_index, parent_media_index, ' \ 'year, started, user ' \ 'FROM session_history_metadata ' \ @@ -466,6 +467,8 @@ class Users(object): recent_output = {'row_id': row['id'], 'media_type': row['media_type'], 'rating_key': row['rating_key'], + 'parent_rating_key': row['parent_rating_key'], + 'grandparent_rating_key': row['grandparent_rating_key'], 'title': row['title'], 'parent_title': row['parent_title'], 'grandparent_title': row['grandparent_title'], diff --git a/plexpy/webserve.py b/plexpy/webserve.py index f35d647b..63f2abae 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -150,6 +150,7 @@ class WebInterface(object): @require() def home(self): config = { + "home_sections": plexpy.CONFIG.HOME_SECTIONS, "home_stats_length": plexpy.CONFIG.HOME_STATS_LENGTH, "home_stats_cards": plexpy.CONFIG.HOME_STATS_CARDS, "home_library_cards": plexpy.CONFIG.HOME_LIBRARY_CARDS, @@ -1290,6 +1291,7 @@ class WebInterface(object): "notify_on_pmsupdate_subject_text": plexpy.CONFIG.NOTIFY_ON_PMSUPDATE_SUBJECT_TEXT, "notify_on_pmsupdate_body_text": plexpy.CONFIG.NOTIFY_ON_PMSUPDATE_BODY_TEXT, "notify_scripts_args_text": plexpy.CONFIG.NOTIFY_SCRIPTS_ARGS_TEXT, + "home_sections": json.dumps(plexpy.CONFIG.HOME_SECTIONS), "home_stats_length": plexpy.CONFIG.HOME_STATS_LENGTH, "home_stats_type": checked(plexpy.CONFIG.HOME_STATS_TYPE), "home_stats_count": plexpy.CONFIG.HOME_STATS_COUNT, @@ -1309,15 +1311,15 @@ class WebInterface(object): checked_configs = [ "launch_browser", "enable_https", "https_create_cert", "api_enabled", "freeze_db", "check_github", - "grouping_global_history", "grouping_user_history", "grouping_charts", "pms_use_bif", "pms_ssl", + "grouping_global_history", "grouping_user_history", "grouping_charts", "group_history_tables", + "pms_use_bif", "pms_ssl", "pms_is_remote", "home_stats_type", "movie_notify_enable", "tv_notify_enable", "music_notify_enable", "monitoring_use_websocket", "tv_notify_on_start", "movie_notify_on_start", "music_notify_on_start", "tv_notify_on_stop", "movie_notify_on_stop", "music_notify_on_stop", "tv_notify_on_pause", "movie_notify_on_pause", "music_notify_on_pause", "refresh_libraries_on_startup", "refresh_users_on_startup", "ip_logging_enable", "movie_logging_enable", "tv_logging_enable", "music_logging_enable", - "pms_is_remote", "home_stats_type", "group_history_tables", "notify_consecutive", "notify_upload_posters", - "notify_recently_added", "notify_recently_added_grandparent", + "notify_consecutive", "notify_upload_posters", "notify_recently_added", "notify_recently_added_grandparent", "monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist", "http_hash_password" ] for checked_config in checked_configs: @@ -1380,8 +1382,15 @@ class WebInterface(object): kwargs.get('https_key') != plexpy.CONFIG.HTTPS_KEY: https_changed = True + # Remove config with 'hsec-' prefix and change home_sections to list + if kwargs.get('home_sections'): + for k in kwargs.keys(): + if k.startswith('hsec-'): + del kwargs[k] + kwargs['home_sections'] = kwargs['home_sections'].split(',') + # Remove config with 'hscard-' prefix and change home_stats_cards to list - if kwargs.get('home_stats_cards', ''): + if kwargs.get('home_stats_cards'): for k in kwargs.keys(): if k.startswith('hscard-'): del kwargs[k] @@ -1391,7 +1400,7 @@ class WebInterface(object): kwargs['home_stats_cards'] = plexpy.CONFIG.HOME_STATS_CARDS # Remove config with 'hlcard-' prefix and change home_library_cards to list - if kwargs.get('home_library_cards', ''): + if kwargs.get('home_library_cards'): for k in kwargs.keys(): if k.startswith('hlcard-'): del kwargs[k] From e99bc73e46e65a4a3c614944f6a711b793a41088 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 01:02:37 -0700 Subject: [PATCH 006/132] Require authentication for all endpoints except API * And more minor UI changes --- data/interfaces/default/base.html | 6 +- data/interfaces/default/settings.html | 11 +-- plexpy/webauth.py | 12 ++- plexpy/webserve.py | 137 ++++++++++++++++++++++++-- plexpy/webstart.py | 3 - 5 files changed, 147 insertions(+), 22 deletions(-) diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index 9045eae7..526cdeb5 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -212,6 +212,8 @@ from plexpy.helpers import anon_url % endif diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 3a5b0033..2b82e0ad 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -132,7 +132,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents() https://gitter.im/drzoidberg33/plexpy - Donations: + Support PlexPy: Paypal | Bitcoin @@ -236,12 +236,11 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
    -

    Homepage

    +

    Sections

    -

    - Select the sections to show on the homepage.
    + Select the sections to show on the homepage. Drag the items below to reorder your homepage content.

    @@ -281,7 +280,6 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
    -

    Select the cards to show in the watch statistics on the home page. Drag the items below to reorder your homepage content. @@ -386,7 +384,6 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

    -

    Select the cards to show in the library statistics on the home page. Drag the items below to reorder your homepage content. @@ -433,7 +430,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

    -

    The base URL for the web server. Used for reverse proxies.

    +

    The base URL of the web server used for reverse proxies.

    diff --git a/plexpy/webauth.py b/plexpy/webauth.py index 8f90cf3d..87d1068e 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -21,6 +21,7 @@ import cherrypy from cgi import escape from hashing_passwords import check_hash +from datetime import datetime, timedelta import plexpy from plexpy import logger @@ -52,8 +53,8 @@ def check_auth(*args, **kwargs): conditions that the user must fulfill""" conditions = cherrypy.request.config.get('auth.require', None) if conditions is not None: - username = cherrypy.session.get(SESSION_KEY) - if username: + (username, expiry) = cherrypy.session.get(SESSION_KEY) if cherrypy.session.get(SESSION_KEY) else (None, None) + if (username and expiry) and expiry > datetime.now(): cherrypy.request.login = username for condition in conditions: # A condition is just a callable that returns true or false @@ -128,17 +129,14 @@ class AuthController(object): def get_loginform(self, username="", msg=""): from plexpy.webserve import serve_template - - username = escape(username, True) - - return serve_template(templatename="login.html", title="Login", username=username, msg=msg) + return serve_template(templatename="login.html", title="Login", username=escape(username, True), msg=msg) @cherrypy.expose def index(self): raise cherrypy.HTTPRedirect("login") @cherrypy.expose - def login(self, username=None, password=None, remember_me=0): + def login(self, username=None, password=None, remember_me='0'): if not plexpy.CONFIG.HTTP_PASSWORD: raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) @@ -152,7 +150,10 @@ class AuthController(object): return self.get_loginform(username, error_msg) else: cherrypy.session.regenerate() - cherrypy.session[SESSION_KEY] = cherrypy.request.login = username + cherrypy.request.login = username + expiry = datetime.now() + (timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)) + cherrypy.session[SESSION_KEY] = (username, expiry) + self.on_login(username) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) @@ -162,7 +163,7 @@ class AuthController(object): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) sess = cherrypy.session - username = sess.get(SESSION_KEY, None) + (username, expiry) = sess.get(SESSION_KEY) if sess.get(SESSION_KEY) else (None, None) sess[SESSION_KEY] = None if username: diff --git a/plexpy/webstart.py b/plexpy/webstart.py index b2ad1515..52d3ff83 100644 --- a/plexpy/webstart.py +++ b/plexpy/webstart.py @@ -66,8 +66,9 @@ def initialize(options): if options['http_password']: logger.info("Web server authentication is enabled, username is '%s'", options['http_username']) - options_dict['tools.sessions.on'] = True options_dict['tools.auth.on'] = True + options_dict['tools.sessions.on'] = True + options_dict['tools.sessions.timeout'] = 30 * 24 * 60 # 30 days cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth) if not options['http_root'] or options['http_root'] == '/': From 24205dc86e126a08ccd1794b49b9d240513ce9d7 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 14:16:13 -0700 Subject: [PATCH 008/132] Fix settings hover nav menu for mobile --- data/interfaces/default/base.html | 31 +++++++------------ data/interfaces/default/css/plexpy.css | 7 ++--- .../js/bootstrap-hover-dropdown.min.js | 12 +++++++ .../js/{bootstrap3 => }/bootstrap.min.js | 0 data/interfaces/default/welcome.html | 2 +- plexpy/config.py | 2 +- 6 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 data/interfaces/default/js/bootstrap-hover-dropdown.min.js rename data/interfaces/default/js/{bootstrap3 => }/bootstrap.min.js (100%) diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index 526cdeb5..bd3a5611 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -210,8 +210,8 @@ from plexpy.helpers import anon_url % else:
    - + + - - ${next.javascriptIncludes()} @@ -324,4 +317,4 @@ ${next.javascriptIncludes()} <%def name="javascriptIncludes()"> <%def name="headIncludes()"> -<%def name="headerIncludes()"> +<%def name="headerIncludes()"> \ No newline at end of file diff --git a/data/interfaces/default/css/plexpy.css b/data/interfaces/default/css/plexpy.css index 265f9a46..72cc1f34 100644 --- a/data/interfaces/default/css/plexpy.css +++ b/data/interfaces/default/css/plexpy.css @@ -99,10 +99,9 @@ img { .navbar-toggle:focus { background-color: #2f2f2f; } -@media screen and (min-width: 768px) { - .dropdown:hover > .dropdown-menu { - display: block; - } +.nav .open > a, .nav .open > a:hover, .nav .open > a:focus { + background-color: #2f2f2f; + border-color: none; } .dropdown-menu { background-color: #282828; diff --git a/data/interfaces/default/js/bootstrap-hover-dropdown.min.js b/data/interfaces/default/js/bootstrap-hover-dropdown.min.js new file mode 100644 index 00000000..89d5c6d5 --- /dev/null +++ b/data/interfaces/default/js/bootstrap-hover-dropdown.min.js @@ -0,0 +1,12 @@ +/** + * @preserve + * Project: Bootstrap Hover Dropdown + * Author: Cameron Spear + * Version: v2.2.1 + * Contributors: Mattia Larentis + * Dependencies: Bootstrap's Dropdown plugin, jQuery + * Description: A simple plugin to enable Bootstrap dropdowns to active on hover and provide a nice user experience. + * License: MIT + * Homepage: http://cameronspear.com/blog/bootstrap-dropdown-on-hover-plugin/ + */ +!function(e,n,o){var t=e();e.fn.dropdownHover=function(o){return"ontouchstart"in document?this:(t=t.add(this.parent()),this.each(function(){function r(e){d.parents(".navbar").find(".navbar-toggle").is(":visible")||(n.clearTimeout(a),n.clearTimeout(i),i=n.setTimeout(function(){t.find(":focus").blur(),f.instantlyCloseOthers===!0&&t.removeClass("open"),n.clearTimeout(i),d.attr("aria-expanded","true"),s.addClass("open"),d.trigger(l)},f.hoverDelay))}var a,i,d=e(this),s=d.parent(),u={delay:500,hoverDelay:0,instantlyCloseOthers:!0},h={delay:e(this).data("delay"),hoverDelay:e(this).data("hover-delay"),instantlyCloseOthers:e(this).data("close-others")},l="show.bs.dropdown",c="hide.bs.dropdown",f=e.extend(!0,{},u,o,h);s.hover(function(e){return s.hasClass("open")||d.is(e.target)?void r(e):!0},function(){n.clearTimeout(i),a=n.setTimeout(function(){d.attr("aria-expanded","false"),s.removeClass("open"),d.trigger(c)},f.delay)}),d.hover(function(e){return s.hasClass("open")||s.is(e.target)?void r(e):!0}),s.find(".dropdown-submenu").each(function(){var o,t=e(this);t.hover(function(){n.clearTimeout(o),t.children(".dropdown-menu").show(),t.siblings().children(".dropdown-menu").hide()},function(){var e=t.children(".dropdown-menu");o=n.setTimeout(function(){e.hide()},f.delay)})})}))},e(document).ready(function(){e(n).width()>769&&e(".navbar .dropdown > a").click(function(){location.href=this.href})}),e(document).ready(function(){e('[data-hover="dropdown"]').dropdownHover()})}(jQuery,window); \ No newline at end of file diff --git a/data/interfaces/default/js/bootstrap3/bootstrap.min.js b/data/interfaces/default/js/bootstrap.min.js similarity index 100% rename from data/interfaces/default/js/bootstrap3/bootstrap.min.js rename to data/interfaces/default/js/bootstrap.min.js diff --git a/data/interfaces/default/welcome.html b/data/interfaces/default/welcome.html index e28831f5..ab2e87b2 100644 --- a/data/interfaces/default/welcome.html +++ b/data/interfaces/default/welcome.html @@ -192,7 +192,7 @@ from plexpy import common
    - + diff --git a/plexpy/config.py b/plexpy/config.py index 453dcb6b..14b413a6 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -617,4 +617,4 @@ class Config(object): home_sections = self.HOME_SECTIONS home_sections.remove('library_stats') self.HOME_SECTIONS = home_sections - self.CONFIG_VERSION = '4' \ No newline at end of file + self.CONFIG_VERSION = '5' \ No newline at end of file From f9825410dc78d237dad51f430461b624d65b2c64 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 14:40:15 -0700 Subject: [PATCH 009/132] Move mask logs toggle to Extra Settings --- data/interfaces/default/settings.html | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 2b82e0ad..880229de 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -195,13 +195,6 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

    Group successive play history by the same user as a single entry in the tables and watch statistics.

    -
    - -

    Enable to mask passwords, access tokens, and public IP addresses with asterisks (*) in the logs.
    - Note: Only logs from the time this setting is enabled will be masked. Do not post your logs publically without masking sensitive information!

    -

    Directories

    @@ -710,6 +703,15 @@ available_notification_agents = sorted(notifiers.available_notification_agents()

    Enable if you want PlexPy to calculate the total file size for TV Shows/Seasons and Artists/Albums on the media info tables.

    +
    + +

    + Enable to mask passwords, access tokens, and public IP addresses with asterisks (*) in the logs.
    + Note: Only logs from the time this setting is enabled will be masked. Do not post your logs publically without masking sensitive information! +

    +
    From b2304992e5a7cea4217547494fe88a76c4ad01be Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 16:24:41 -0700 Subject: [PATCH 010/132] Update CherryPy to 5.1.0 --- lib/cherrypy/__init__.py | 4 +- lib/cherrypy/_cpcompat.py | 30 +- lib/cherrypy/_cpcompat_subprocess.py | 4 +- lib/cherrypy/_cpconfig.py | 2 +- lib/cherrypy/_cpdispatch.py | 1 - lib/cherrypy/_cpwsgi.py | 3 +- lib/cherrypy/daemon.py | 15 +- lib/cherrypy/lib/auth_digest.py | 3 +- lib/cherrypy/lib/cpstats.py | 18 +- lib/cherrypy/lib/cptools.py | 10 +- lib/cherrypy/lib/encoding.py | 2 +- lib/cherrypy/lib/httpauth.py | 4 +- lib/cherrypy/lib/profiler.py | 2 +- lib/cherrypy/lib/reprconf.py | 42 +- lib/cherrypy/lib/static.py | 5 +- lib/cherrypy/process/plugins.py | 35 +- lib/cherrypy/process/servers.py | 3 +- lib/cherrypy/process/wspbus.py | 2 - lib/cherrypy/scaffold/apache-fcgi.conf | 22 + lib/cherrypy/scaffold/example.conf | 3 + lib/cherrypy/scaffold/site.conf | 14 + .../static/made_with_cherrypy_small.png | Bin 0 -> 7455 bytes lib/cherrypy/wsgiserver/ssl_pyopenssl.py | 4 +- lib/cherrypy/wsgiserver/wsgiserver2.py | 98 +- lib/cherrypy/wsgiserver/wsgiserver3.py | 2187 +++++++++++++++++ 25 files changed, 2383 insertions(+), 130 deletions(-) create mode 100644 lib/cherrypy/scaffold/apache-fcgi.conf create mode 100644 lib/cherrypy/scaffold/example.conf create mode 100644 lib/cherrypy/scaffold/site.conf create mode 100644 lib/cherrypy/scaffold/static/made_with_cherrypy_small.png create mode 100644 lib/cherrypy/wsgiserver/wsgiserver3.py diff --git a/lib/cherrypy/__init__.py b/lib/cherrypy/__init__.py index 6779f170..6e2f9bdb 100644 --- a/lib/cherrypy/__init__.py +++ b/lib/cherrypy/__init__.py @@ -56,10 +56,10 @@ with customized or extended components. The core API's are: These API's are described in the `CherryPy specification `_. """ -__version__ = "3.8.0" +__version__ = "5.1.0" from cherrypy._cpcompat import urljoin as _urljoin, urlencode as _urlencode -from cherrypy._cpcompat import basestring, unicodestr, set +from cherrypy._cpcompat import basestring, unicodestr from cherrypy._cperror import HTTPError, HTTPRedirect, InternalRedirect from cherrypy._cperror import NotFound, CherryPyException, TimeoutError diff --git a/lib/cherrypy/_cpcompat.py b/lib/cherrypy/_cpcompat.py index 8a98b38b..a73feb0b 100644 --- a/lib/cherrypy/_cpcompat.py +++ b/lib/cherrypy/_cpcompat.py @@ -110,11 +110,6 @@ def assert_native(n): if not isinstance(n, nativestr): raise TypeError("n must be a native str (got %s)" % type(n).__name__) -try: - set = set -except NameError: - from sets import Set as set - try: # Python 3.1+ from base64 import decodebytes as _base64_decodebytes @@ -137,17 +132,6 @@ def base64_decode(n, encoding='ISO-8859-1'): else: return b -try: - # Python 2.5+ - from hashlib import md5 -except ImportError: - from md5 import new as md5 - -try: - # Python 2.5+ - from hashlib import sha1 as sha -except ImportError: - from sha import new as sha try: sorted = sorted @@ -333,18 +317,10 @@ except ImportError: # In Python 3, pickle is the sped-up C version. import pickle -try: - os.urandom(20) - import binascii +import binascii - def random20(): - return binascii.hexlify(os.urandom(20)).decode('ascii') -except (AttributeError, NotImplementedError): - import random - # os.urandom not available until Python 2.4. Fall back to random.random. - - def random20(): - return sha('%s' % random.random()).hexdigest() +def random20(): + return binascii.hexlify(os.urandom(20)).decode('ascii') try: from _thread import get_ident as get_thread_ident diff --git a/lib/cherrypy/_cpcompat_subprocess.py b/lib/cherrypy/_cpcompat_subprocess.py index 478f4a74..e3d5109f 100644 --- a/lib/cherrypy/_cpcompat_subprocess.py +++ b/lib/cherrypy/_cpcompat_subprocess.py @@ -883,7 +883,7 @@ class Popen(object): startupinfo.dwFlags |= _subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = _subprocess.SW_HIDE comspec = os.environ.get("COMSPEC", "cmd.exe") - args = '{} /c "{}"'.format(comspec, args) + args = '{0} /c "{1}"'.format(comspec, args) if (_subprocess.GetVersion() >= 0x80000000 or os.path.basename(comspec).lower() == "command.com"): # Win9x, or using command.com on NT. We need to @@ -1029,7 +1029,7 @@ class Popen(object): elif sig == signal.CTRL_BREAK_EVENT: os.kill(self.pid, signal.CTRL_BREAK_EVENT) else: - raise ValueError("Unsupported signal: {}".format(sig)) + raise ValueError("Unsupported signal: {0}".format(sig)) def terminate(self): """Terminates the process diff --git a/lib/cherrypy/_cpconfig.py b/lib/cherrypy/_cpconfig.py index c11bc1d1..00207723 100644 --- a/lib/cherrypy/_cpconfig.py +++ b/lib/cherrypy/_cpconfig.py @@ -119,7 +119,7 @@ style) context manager. """ import cherrypy -from cherrypy._cpcompat import set, basestring +from cherrypy._cpcompat import basestring from cherrypy.lib import reprconf # Deprecated in CherryPy 3.2--remove in 3.3 diff --git a/lib/cherrypy/_cpdispatch.py b/lib/cherrypy/_cpdispatch.py index 1c2d7df8..710bb3fd 100644 --- a/lib/cherrypy/_cpdispatch.py +++ b/lib/cherrypy/_cpdispatch.py @@ -18,7 +18,6 @@ except AttributeError: classtype = type import cherrypy -from cherrypy._cpcompat import set class PageHandler(object): diff --git a/lib/cherrypy/_cpwsgi.py b/lib/cherrypy/_cpwsgi.py index f6db68b0..a8068fb0 100644 --- a/lib/cherrypy/_cpwsgi.py +++ b/lib/cherrypy/_cpwsgi.py @@ -296,7 +296,8 @@ class AppResponse(object): """Create a Request object using environ.""" env = self.environ.get - local = httputil.Host('', int(env('SERVER_PORT', 80)), + local = httputil.Host('', + int(env('SERVER_PORT', 80) or -1), env('SERVER_NAME', '')) remote = httputil.Host(env('REMOTE_ADDR', ''), int(env('REMOTE_PORT', -1) or -1), diff --git a/lib/cherrypy/daemon.py b/lib/cherrypy/daemon.py index d71e6329..395a2e68 100644 --- a/lib/cherrypy/daemon.py +++ b/lib/cherrypy/daemon.py @@ -53,15 +53,12 @@ def start(configfiles=None, daemonize=False, environment=None, cherrypy.server.unsubscribe() addr = cherrypy.server.bind_addr - if fastcgi: - f = servers.FlupFCGIServer(application=cherrypy.tree, - bindAddress=addr) - elif scgi: - f = servers.FlupSCGIServer(application=cherrypy.tree, - bindAddress=addr) - else: - f = servers.FlupCGIServer(application=cherrypy.tree, - bindAddress=addr) + cls = ( + servers.FlupFCGIServer if fastcgi else + servers.FlupSCGIServer if scgi else + servers.FlupCGIServer + ) + f = cls(application=cherrypy.tree, bindAddress=addr) s = servers.ServerAdapter(engine, httpserver=f, bind_addr=addr) s.subscribe() diff --git a/lib/cherrypy/lib/auth_digest.py b/lib/cherrypy/lib/auth_digest.py index e06535dc..e833ff77 100644 --- a/lib/cherrypy/lib/auth_digest.py +++ b/lib/cherrypy/lib/auth_digest.py @@ -23,10 +23,11 @@ __date__ = 'April 2009' import time +from hashlib import md5 from cherrypy._cpcompat import parse_http_list, parse_keqv_list import cherrypy -from cherrypy._cpcompat import md5, ntob +from cherrypy._cpcompat import ntob md5_hex = lambda s: md5(ntob(s)).hexdigest() qop_auth = 'auth' diff --git a/lib/cherrypy/lib/cpstats.py b/lib/cherrypy/lib/cpstats.py index a8661a14..4aeabd7d 100644 --- a/lib/cherrypy/lib/cpstats.py +++ b/lib/cherrypy/lib/cpstats.py @@ -210,6 +210,7 @@ def extrapolate_statistics(scope): # -------------------- CherryPy Applications Statistics --------------------- # +import sys import threading import time @@ -294,6 +295,11 @@ class ByteCountWrapper(object): average_uriset_time = lambda s: s['Count'] and (s['Sum'] / s['Count']) or 0 +def _get_threading_ident(): + if sys.version_info >= (3, 3): + return threading.get_ident() + return threading._get_ident() + class StatsTool(cherrypy.Tool): """Record various information about the current request.""" @@ -322,7 +328,7 @@ class StatsTool(cherrypy.Tool): appstats['Current Requests'] += 1 appstats['Total Requests'] += 1 - appstats['Requests'][threading._get_ident()] = { + appstats['Requests'][_get_threading_ident()] = { 'Bytes Read': None, 'Bytes Written': None, # Use a lambda so the ip gets updated by tools.proxy later @@ -339,7 +345,7 @@ class StatsTool(cherrypy.Tool): debug=False, **kwargs): """Record the end of a request.""" resp = cherrypy.serving.response - w = appstats['Requests'][threading._get_ident()] + w = appstats['Requests'][_get_threading_ident()] r = cherrypy.request.rfile.bytes_read w['Bytes Read'] = r @@ -605,7 +611,13 @@ table.stats2 th { """Return ([headers], [rows]) for the given collection.""" # E.g., the 'Requests' dict. headers = [] - for record in v.itervalues(): + try: + # python2 + vals = v.itervalues() + except AttributeError: + # python3 + vals = v.values() + for record in vals: for k3 in record: format = formatting.get(k3, missing) if format is None: diff --git a/lib/cherrypy/lib/cptools.py b/lib/cherrypy/lib/cptools.py index f376282c..9be571ba 100644 --- a/lib/cherrypy/lib/cptools.py +++ b/lib/cherrypy/lib/cptools.py @@ -2,9 +2,10 @@ import logging import re +from hashlib import md5 import cherrypy -from cherrypy._cpcompat import basestring, md5, set, unicodestr +from cherrypy._cpcompat import basestring, unicodestr from cherrypy.lib import httputil as _httputil from cherrypy.lib import is_iterator @@ -192,11 +193,10 @@ def proxy(base=None, local='X-Forwarded-Host', remote='X-Forwarded-For', if lbase is not None: base = lbase.split(',')[0] if not base: + base = request.headers.get('Host', '127.0.0.1') port = request.local.port - if port == 80: - base = '127.0.0.1' - else: - base = '127.0.0.1:%s' % port + if port != 80: + base += ':%s' % port if base.find("://") == -1: # add http:// or https:// if needed diff --git a/lib/cherrypy/lib/encoding.py b/lib/cherrypy/lib/encoding.py index a4c2cbd6..fb688f8d 100644 --- a/lib/cherrypy/lib/encoding.py +++ b/lib/cherrypy/lib/encoding.py @@ -2,7 +2,7 @@ import struct import time import cherrypy -from cherrypy._cpcompat import basestring, BytesIO, ntob, set, unicodestr +from cherrypy._cpcompat import basestring, BytesIO, ntob, unicodestr from cherrypy.lib import file_generator from cherrypy.lib import is_closable_iterator from cherrypy.lib import set_vary_header diff --git a/lib/cherrypy/lib/httpauth.py b/lib/cherrypy/lib/httpauth.py index 0897ea2a..6d519907 100644 --- a/lib/cherrypy/lib/httpauth.py +++ b/lib/cherrypy/lib/httpauth.py @@ -62,7 +62,9 @@ __all__ = ("digestAuth", "basicAuth", "doAuth", "checkResponse", ########################################################################## import time -from cherrypy._cpcompat import base64_decode, ntob, md5 +from hashlib import md5 + +from cherrypy._cpcompat import base64_decode, ntob from cherrypy._cpcompat import parse_http_list, parse_keqv_list MD5 = "MD5" diff --git a/lib/cherrypy/lib/profiler.py b/lib/cherrypy/lib/profiler.py index 5dac386e..a3477454 100644 --- a/lib/cherrypy/lib/profiler.py +++ b/lib/cherrypy/lib/profiler.py @@ -8,7 +8,7 @@ You can profile any of your pages as follows:: from cherrypy.lib import profiler class Root: - p = profile.Profiler("/path/to/profile/dir") + p = profiler.Profiler("/path/to/profile/dir") def index(self): self.p.run(self._index) diff --git a/lib/cherrypy/lib/reprconf.py b/lib/cherrypy/lib/reprconf.py index 6e70b5ec..8af1f777 100644 --- a/lib/cherrypy/lib/reprconf.py +++ b/lib/cherrypy/lib/reprconf.py @@ -281,13 +281,14 @@ class _Builder2: # Everything else becomes args else : args.append(self.build(child)) + return callee(*args, **kwargs) def build_Keyword(self, o): key, value_obj = o.getChildren() value = self.build(value_obj) kw_dict = {key: value} - return kw_dict + return kw_dict def build_List(self, o): return map(self.build, o.getChildren()) @@ -377,7 +378,39 @@ class _Builder3: def build_Index(self, o): return self.build(o.value) + def _build_call35(self, o): + """ + Workaround for python 3.5 _ast.Call signature, docs found here + https://greentreesnakes.readthedocs.org/en/latest/nodes.html + """ + import ast + callee = self.build(o.func) + args = [] + if o.args is not None: + for a in o.args: + if isinstance(a, ast.Starred): + args.append(self.build(a.value)) + else: + args.append(self.build(a)) + kwargs = {} + for kw in o.keywords: + if kw.arg is None: # double asterix `**` + rst = self.build(kw.value) + if not isinstance(rst, dict): + raise TypeError("Invalid argument for call." + "Must be a mapping object.") + # give preference to the keys set directly from arg=value + for k, v in rst.items(): + if k not in kwargs: + kwargs[k] = v + else: # defined on the call as: arg=value + kwargs[kw.arg] = self.build(kw.value) + return callee(*args, **kwargs) + def build_Call(self, o): + if sys.version_info >= (3, 5): + return self._build_call35(o) + callee = self.build(o.func) if o.args is None: @@ -388,13 +421,16 @@ class _Builder3: if o.starargs is None: starargs = () else: - starargs = self.build(o.starargs) + starargs = tuple(self.build(o.starargs)) if o.kwargs is None: kwargs = {} else: kwargs = self.build(o.kwargs) - + if o.keywords is not None: # direct a=b keywords + for kw in o.keywords: + # preference because is a direct keyword against **kwargs + kwargs[kw.arg] = self.build(kw.value) return callee(*(args + starargs), **kwargs) def build_List(self, o): diff --git a/lib/cherrypy/lib/static.py b/lib/cherrypy/lib/static.py index a630dae6..6a78fc13 100644 --- a/lib/cherrypy/lib/static.py +++ b/lib/cherrypy/lib/static.py @@ -49,7 +49,10 @@ def serve_file(path, content_type=None, disposition=None, name=None, try: st = os.stat(path) - except OSError: + except (OSError, TypeError, ValueError): + # OSError when file fails to stat + # TypeError on Python 2 when there's a null byte + # ValueError on Python 3 when there's a null byte if debug: cherrypy.log('os.stat(%r) failed' % path, 'TOOLS.STATIC') raise cherrypy.NotFound() diff --git a/lib/cherrypy/process/plugins.py b/lib/cherrypy/process/plugins.py index c787ba92..0ec585c0 100644 --- a/lib/cherrypy/process/plugins.py +++ b/lib/cherrypy/process/plugins.py @@ -8,7 +8,7 @@ import time import threading from cherrypy._cpcompat import basestring, get_daemon, get_thread_ident -from cherrypy._cpcompat import ntob, set, Timer, SetDaemonProperty +from cherrypy._cpcompat import ntob, Timer, SetDaemonProperty # _module__file__base is used by Autoreload to make # absolute any filenames retrieved from sys.modules which are not @@ -109,12 +109,35 @@ class SignalHandler(object): self.handlers['SIGINT'] = self._jython_SIGINT_handler self._previous_handlers = {} + # used to determine is the process is a daemon in `self._is_daemonized` + self._original_pid = os.getpid() + def _jython_SIGINT_handler(self, signum=None, frame=None): # See http://bugs.jython.org/issue1313 self.bus.log('Keyboard Interrupt: shutting down bus') self.bus.exit() + def _is_daemonized(self): + """Return boolean indicating if the current process is + running as a daemon. + + The criteria to determine the `daemon` condition is to verify + if the current pid is not the same as the one that got used on + the initial construction of the plugin *and* the stdin is not + connected to a terminal. + + The sole validation of the tty is not enough when the plugin + is executing inside other process like in a CI tool + (Buildbot, Jenkins). + """ + if (self._original_pid != os.getpid() and + not os.isatty(sys.stdin.fileno())): + return True + else: + return False + + def subscribe(self): """Subscribe self.handlers to signals.""" for sig, func in self.handlers.items(): @@ -180,13 +203,13 @@ class SignalHandler(object): def handle_SIGHUP(self): """Restart if daemonized, else exit.""" - if os.isatty(sys.stdin.fileno()): + if self._is_daemonized(): + self.bus.log("SIGHUP caught while daemonized. Restarting.") + self.bus.restart() + else: # not daemonized (may be foreground or background) self.bus.log("SIGHUP caught but not daemonized. Exiting.") self.bus.exit() - else: - self.bus.log("SIGHUP caught while daemonized. Restarting.") - self.bus.restart() try: @@ -200,7 +223,7 @@ class DropPrivileges(SimplePlugin): """Drop privileges. uid/gid arguments not available on Windows. - Special thanks to `Gavin Baker `_ + Special thanks to `Gavin Baker `_ """ def __init__(self, bus, umask=None, uid=None, gid=None): diff --git a/lib/cherrypy/process/servers.py b/lib/cherrypy/process/servers.py index 6f8088bd..91ebf604 100644 --- a/lib/cherrypy/process/servers.py +++ b/lib/cherrypy/process/servers.py @@ -183,8 +183,7 @@ class ServerAdapter(object): if not self.httpserver: return '' host, port = self.bind_addr - if getattr(self.httpserver, 'ssl_certificate', None) or \ - getattr(self.httpserver, 'ssl_adapter', None): + if getattr(self.httpserver, 'ssl_adapter', None): scheme = "https" if port != 443: host += ":%s" % port diff --git a/lib/cherrypy/process/wspbus.py b/lib/cherrypy/process/wspbus.py index 5409d038..c9de3511 100644 --- a/lib/cherrypy/process/wspbus.py +++ b/lib/cherrypy/process/wspbus.py @@ -68,8 +68,6 @@ import time import traceback as _traceback import warnings -from cherrypy._cpcompat import set - # Here I save the value of os.getcwd(), which, if I am imported early enough, # will be the directory from which the startup script was run. This is needed # by _do_execv(), to change back to the original directory before execv()ing a diff --git a/lib/cherrypy/scaffold/apache-fcgi.conf b/lib/cherrypy/scaffold/apache-fcgi.conf new file mode 100644 index 00000000..922398ea --- /dev/null +++ b/lib/cherrypy/scaffold/apache-fcgi.conf @@ -0,0 +1,22 @@ +# Apache2 server conf file for using CherryPy with mod_fcgid. + +# This doesn't have to be "C:/", but it has to be a directory somewhere, and +# MUST match the directory used in the FastCgiExternalServer directive, below. +DocumentRoot "C:/" + +ServerName 127.0.0.1 +Listen 80 +LoadModule fastcgi_module modules/mod_fastcgi.dll +LoadModule rewrite_module modules/mod_rewrite.so + +Options ExecCGI +SetHandler fastcgi-script +RewriteEngine On +# Send requests for any URI to our fastcgi handler. +RewriteRule ^(.*)$ /fastcgi.pyc [L] + +# The FastCgiExternalServer directive defines filename as an external FastCGI application. +# If filename does not begin with a slash (/) then it is assumed to be relative to the ServerRoot. +# The filename does not have to exist in the local filesystem. URIs that Apache resolves to this +# filename will be handled by this external FastCGI application. +FastCgiExternalServer "C:/fastcgi.pyc" -host 127.0.0.1:8088 \ No newline at end of file diff --git a/lib/cherrypy/scaffold/example.conf b/lib/cherrypy/scaffold/example.conf new file mode 100644 index 00000000..93a6e53c --- /dev/null +++ b/lib/cherrypy/scaffold/example.conf @@ -0,0 +1,3 @@ +[/] +log.error_file: "error.log" +log.access_file: "access.log" \ No newline at end of file diff --git a/lib/cherrypy/scaffold/site.conf b/lib/cherrypy/scaffold/site.conf new file mode 100644 index 00000000..6ed38983 --- /dev/null +++ b/lib/cherrypy/scaffold/site.conf @@ -0,0 +1,14 @@ +[global] +# Uncomment this when you're done developing +#environment: "production" + +server.socket_host: "0.0.0.0" +server.socket_port: 8088 + +# Uncomment the following lines to run on HTTPS at the same time +#server.2.socket_host: "0.0.0.0" +#server.2.socket_port: 8433 +#server.2.ssl_certificate: '../test/test.pem' +#server.2.ssl_private_key: '../test/test.pem' + +tree.myapp: cherrypy.Application(scaffold.root, "/", "example.conf") diff --git a/lib/cherrypy/scaffold/static/made_with_cherrypy_small.png b/lib/cherrypy/scaffold/static/made_with_cherrypy_small.png new file mode 100644 index 0000000000000000000000000000000000000000..c3aafeed952190f5da9982bb359aa75b107ff079 GIT binary patch literal 7455 zcmeAS@N?(olHy`uVBq!ia0y~yV3@|hz+lY5#=yW(dg0@K1_lO}bVpxD28Q(~%vrl$ zGB7ZRm$*ih1m~xflqVLYG6W=M=9TFAxrQi|8R?m+_RVl%V367D>Eakt5%)HhJ!0zd zOMd+;v#K^;&k*r8`6iUydF+NM+m#!F)r&pYGnHZ;CTM7MH6=N-yz~_ia9mOzQ04B@ zd+PW3ca6#?EDH>Z@^{$Vows@Zd)t|F=l0!y^X8q-KE7YyZ|#30AMiWMv+iB?)zGKw zzrL~mvDN%u&Fgrz#C8katOmx{_dmV=cvy-hrtDDT+tVAi$h0p99 zx1dmJ`>gh-dltJcJ^6v_>Y=F$t`%|N%-XjBlrCep~gwPIJOutAwj|Zf_sQFgY`2onk6H zS}wjlNPO@11$#ai&AZDlbm{Ucha*R1g}AlY*8HhXm%f@Av487%)9-wb55}IZ=RInX z>-diMt=XjK0;@Lb9{J?=J<#n`Xsg(*ZBD-L1g|`qf8el^RJ;54DBgA9%sNJMxAq%l zEbDT}{O4*B7saUkY3ZUx{7sE7ToqJQZa!-I|1M2a{q+@XmT9J%lQe(dcg(tL=lJ9z zZ?2O6iG@=GelFwI3hJ@=|LIBdic|UeamF}H+ zwVeN9-L{we+qV3!?Tqf*l&j>w=(gUDyt#d=e3H+_@BTmCqv*bF&g)HoWadneyS{4M z>f$p2^Afv`^S|@rSy#`oCW!CIU*-TbS-3At>Mt_&b<#Corz!lJqEo_F=bE~Wgn z%J<10@pWw_@|B5IuCo*$#cW?>bT?qev`0JHTQA;9C{mBPbMChOp0&JB^{tg})~frj zKV#6;e#>S~jHO)p;VTvUoz{jgH1UjAx+}6QV)3?bD_tJ^NB*DD+c{Rd@|Nv)QjW~G zTGJf$-Twra?1Ptve$pG?_Ffl!t)!mOd}*rP{|N`Sw|};^a@@a zwSAq&A`|_!KQFxaYk5ZP+i}lH;j7<^@6rG6d2ZU5;5lpD{}rX&W`2G1aEQ&KtA}T5 zXYcTzIxVML{b`)i$`9*|&;812?>=wVsbP09N=Lsix5In=$*o8K`ux(0b`0dpUf|ap zIdj?R*Qu;;j`nT8=kU)r^k3Y(E%Md&uUbUDzxDmm7Ukt!|6;q1?XxFU8uecdyYjQI zOf_yexZ&N7;xgf%cE2nP=GY25yh^d&mZ5Sk_uooE|7+Lwows+p^mMJw-M}=RH`DtL zt+YS>`Fl%ubVs`RIsF3#oQ=!o2ONu?dr!?FbAeWD|MJSrbIoVlO?}$#Wz(go_*rg zmMWWbd9#?*+J7$jcjCj{-rD#YmvcMKVp5i0FxlO3opbA?XFuP}e9GCdRPV|2r9Y#U zE4_rb{9q3fSbAwu-72G!i6zdG{{Plx3dZQ$ie9r@>lSy;a^K{-v-RThro@VRUv(8P zyp?S{XWdCppO2l>&VI^Sw^yxlY50zn>rY*nID_TDub7B^Kl)}dM11`6_4>!eU|!~H zcVB->xw}j0M2RG$52JZbN`l2EcK!0AqY^jtC2y{X-OO1lSX-payUa-b2hXipnth?l z|9zQLJ7+1S`s~@mDrIZ08lP|ZGkJ5RqIH1)TEV@jy z?h~WsvFHbjt@+#2`NWppFNttH@ao+Czr5l_rzh11d(Ox=Ol8P5U*q69OU$?R?uk{t zPtKn`dF<2o%UtJw9KUB$`}Se#-!IP&cl=o?T;C&hjn^=qYjWYt4Ri`l?v- z7Usy+?nl>E#1!p|{KOf0uyeI^lAvMm){n1}Jon|81uQC5gvzuZ1tu!~tNS$J9K-Yj`Vox% znH!>%`V}f~>|S5%7cOOE?vwu^X3ATIY5LY_6%*d~bnW(Ap;>Tn@uKHOD(;bqt!Iq> z7-h5h$=uX#V!yh#ykm;TZz~~&HG8LMsxO!>{6f<3!uF|m{&PfB>aF|m-oAzTl`EH& zp_pM-m&H0u*^Bd@oKUk^VD;5PPIS&f#xgO_d5T;XRjFH#r1&3LeWp0)$m(SeOcpu& zJbbmJoq1RFhR{6KYyWnNM4Nd&Uht01Mn_~zt^P&6_`08xyDM83U)z23)RzqhCZ4=jhk)mLE7?puVSM+oM_E#A1RcH*f!z#xf(<@yO4!8eQu5 z--&+HwVWa*rJuajMITzb9Lo~b4y-fR2`5T9}PA;vy zTa!YIvt0fqYFQLzEecl%bKl{lCXDFTS-t@7Y>E_{@Y8w^y{d)t_dN`Td|8v}*$6UCw+J4SE zH|g#7zqN7yt@`r9zb#S2>6l;J{5wt}>e2f))ukSjSh%ENkzCY|>nAGfCroQ>a6CMl zb!zJ`3!TvD?5GQ7wJ) zV!*O*|7y(daWO7F{wQesJ`VfbhBeXZ0W;q8teMTXqtc5#uRHCb6k|_s_>4s+wJX%) zJM}gt=>`PNxFfVqiv8E!IOW?qD`p)1^xm9h;p63xt_5YPya{xAmnj~#cWTio+u~`< z@5XJG`X1ajLDb@$ruki=ZQ>~^YVQ*&=-uut- zT<=$8uU7i4ajZw~Oykox3J*T-_xLWPBWO^k#2)6iqjIv{wF%k}_h<3RpFbVs99TPD z=acpY*RVB~u_wJJ%S4=sGTf|nYJJv>W6KV1@O%ynAGOz?>pffq%}+7~%BHic)_lL~Q1jyRz*>0s=lhz78TIu|D!sa=-?N2;}=h!dh)V{oTgS}3_!TP^@&V9}P{je@f zc1_K*T;mvaWe1!*)G6aBAb`e8G@ANfU$RtrDg3{_Yd<>{_Mq?bA|)!|E$)Kd~IW zw{Oa_wv1EVkvm09E9*HmHGDjk0t`PaRg-b3Tf`dXasKe@E#*u0l#6~Bl=>w6J!WQZ zx1@dVg9ApFxedJFb(pIltOD z@3EHFW#0U~U&X#Zzw!S0{Q5rD;^PYaPgdnypNN$eR_i$)9$(wK+N7hp{N$qa^Kuo> z=azrC=q{i1_tnlFvTOQJPF7!3{XMT>(`~M+wb2*%*W1TEYv#8*aObA*g0;KY9v|yF z`oNL7!2I4GPy5&tpZ?jrRp*&3)w9XxSAggGot$DH=Km7D!x53Wk4>pm`1Qih2M?1A zcHMUfNwaz|?P}hghz%l*_UE1aCge@F+OC(a^y+-+hZ$zVtQSSzTu=D(YQAw&Pjvdm zNg<}HOw;1uJ90>xHd?*8a>~?z30Z~xxTk6?tL|!oN=`@LZH(lWJVsJ_x02b z7TcFZTDb%YEsdP^MRoNBhA+own{4z^Zk!-8fPr}aHMAf;y7!uBQl#Jc~o z?LFmA@!8C7y%GmLOCDNhzcrPsd>^|rIBG%0pmU^e3nPteSz4ZB`_KD38Ng2Ez{4o**;hE#95`f8hfW7g@9njco{?_Oke{h#H0tGKT=lJS0} zZ$kt$UN>$nxmY5ybp9fn=){?ye6o3@FU{qDG)FRMO8Vi?UYE`+-0|C=Pm4L%^50go zC)JnAl-FNg`uX=0mDwNnG^qrCdHdJY|NocC=Q=o+@OSEHKlfy)m{2W}Dk&ymyGdUE z^S$c151yu-I;f~o-1E!L-S_4+iT{fiocW^JT>1a?@58qu4DzkcTR-05KO=5V7T@~J zV9O0bdrMn3ZQAwyQSXD&D9MG{*ABHVJGf$VPM57u-856CaPB|rRtvveV13*xqW|UD z>f=&HKiCxxRD>@s%(iHIxt%A9eN9U&*CfVnsWk0ggQ2p##D`uX0dg$y=turjTHM)dX0b28L^wKO^qUollMNAihGvJmFT&9 zrM8anqlG>1_sjR>@BhgpYRPh9*RG?QYBCWyPsLMqusZs^;;>rBD9Yccu(-cYiD24UU{K&!ODP`I-KalXlF3Q#|CS&ah^BDRML@ zY>w8>{;6v=#wR5h9x;Bv!XtC+`G*M?g)KLxr+G!ri@)!<;+#>wP_(RryO5fQfJWaT z<%k9QS+9tQ6()Rr`IVKo?TO)Q(V5G58@BqT`P*4MI_x`s`!RcYGs_mN0)#b2AL{WJM{ zo6k>>FDN|_U?F_r&A#9BoHE0dbhn(FW!~{M-KhHfJPixqn;RWY=s#((X>#j3eDv(4 zv#+fGPScZh+RhYVS#~^%b}xGrn|%`MBi(d1Y!WW5 zkgeU?DiiVkqT~C$7R(d(xO~(7h$9}&rcdF`1UA${&VbzDfl~Oh*KgEPjKU!KX z>aydFd)5oZkf(DVD2ObTpC(r9GVOO{cpLA+&F7o9Fqt%Ne^fO`nS1)9{3~mEUIyOM z$mZQ}%E>97E8@{Mb)hNs;?nMOJrAZXOMiBFLefsDNvysTzixb#=T~${woE!XvomQ^ zq))Zv(dPeF-VgQ|yGMk~`7OvK#(b*9kom;*<>x=!GLhq>RfDu$TL+)n zvndjpjZ;=Qdn}$BR5piKYtx#Fxu1HPBYxbjVBC;n)-g4(>?;rBhLTzD-Mw1a?0#(! z)|Y;#+8@AQ$s=thVzmGKf`e%fubxl-_Wsx<9^+E2`IjE)znvUm-{Y=$^j*BPsm%2i zd531M_23D0{?OD~Fwc7K@5Hlrc|AG<)Vr^C@m^YP%Aa|?_J}s4xV4ZL*RqZyZ<>=d zC;8pv-^ZXM?td#{?do8cjZbVfO=j>$#dlpi*SFAibJ~xq&!;YXm3&L&pq&`=gcVlp zd>)rWew4{>xRD^+GVz-e-&6_*k?cly=-{bs)lkLK@9}-Icc5$8ZHd6V%G2-Mkiw|dWm$zlZa zO?Q(2gJ*7Hud6h=C$WFMYAGE`LI%CwM=viyJR|>a(stRi}QaUYvCGOn! zEowar1@7qtsce;eyIp*)#)ST>mnZyhRbq+osrX#0;eGY+n)$2;tO_gUp7=CXl5Ikq zktf5NzbQwfAH00Ir*pcjXW_@_lQ-op=d8ξb^d7?(E1>b^p1aAHqz^4_gi*iN74 z2x=0ye3f^&T`3}*uTMEcbiU`b9g;sI;+6KiNcf)^`nsXi-frS=FWtBM%%{s+q-b~@ zzS-J5?eXc=Mqf16M22y!^XWR56S3YVRDA#0Kl8L_{WROWyuac4&9f(VI|qvL{&;>o zQ^7W9=JKg3?~nUWX64(uQZJH2u;|4KooD~9?6_FVx;=P>k7}$)#Qr3m_22Dsxihr> zv|T9>yK-DFnB!N_oF%JJ!*fFDQ@2>}*OR}b4JwW1pG%T?ajc)^S6)Qh*MrZucYb(f$zc93 zf{TBt{c8u+x;tGjgWP9q7i;YLH^nKNZ(-!k5ZUJGY}4ZBzc08x^WEuGqodw_3m4u_ zc)Y9S-QJfbE5oO(=xr@#aYzgfT_0z=q2Qj$o7>ul&23DlTu(l{>*|HADUj(SE( z*t;>f3C~ScdKz$ZbHisTyDjXD-RoMeo{aL?cmE1!(_fWs+IEG9G;*d#bM@9{FVNY~ zHrwn^ji!CZ+U%Iy>xopSK*yCXEM+4 zbhcZ-5xcBSTPZKkyn{$)^^OPTkkHs}Lw%J>LEApQ4 z=%=sK6PdBTE&hw5ja+LZZKBuaE&v*BX457w2ur$sJ?uiRB` zx9``Y)df=P@*jV_o_SR5=rVuhIQ92^TDjZ4Je|w*ywjDH3n0zB+HP><-J_d+zZ|tC{w>ffl}hCjLsfq|<+> zqVz}XozCO^D!;u=l#=gB?g;wI^YH%n>drOc4O2um`P=i~nEvIEQ}*f5R4FC(YaM)3 zuReIRLHgXc-4EU^>S-xRj`E$woxIIEsQ2ZE>yzTS#jmnOTWX z2{*4F*eP6HVJ+^b`5=wquKJmrnB^*0r+i+uAeXQ3uV${}Jn`3a%Okq|?Ot*z&z+i7 zX|&V(<(35|ce`#)vzfL(G1~B*h~MAGE)}P|yRSLlJvw=kN@+{XMCY)q6)xAjmK@EB z_?(z0cjI5!oolgMB(<)xq`bMI7U|4hzWBVNZp=Kt3z2tBxqrT0F#W1y^^=mBzt}Z{ zt}wY?-}CZN*56kP#401dEbn|#_t171#{oV5Eum+={x`aBbZPeN%V}q3S@H&%91Z)F zx$9}S{94_^NspTKa-2Kfu+5RtYBjp0QajOIGxmZ9_xYi`$B72R>-M6(* ze{q-GWIgXJqOG=@^Pb4+r@Oq_W(IQYncH*1Ho@?%`Q9f5XDel$_M8m}oOWBwJ2d{s zRHoJQG;crOFNe}-& zMX^1WD^EndzT}tBule?OTHYRMnd^|F5<6cx{#ByyQ*XvqzKvh|Rese+{Jgn2-N4+q zwae(Q<>J-vDt>Kw`!A{F&CJUmE?jeaa= (3, 0): bytestr = bytes @@ -165,8 +163,6 @@ ASTERISK = ntob('*') FORWARD_SLASH = ntob('/') quoted_slash = re.compile(ntob("(?i)%2F")) -import errno - def plat_specific_errors(*errnames): """Return error numbers for all errors in errnames on this platform. @@ -210,7 +206,6 @@ comma_separated_headers = [ ] -import logging if not hasattr(logging, 'statistics'): logging.statistics = {} @@ -674,6 +669,10 @@ class HTTPRequest(object): # uri may be an abs_path (including "http://host.domain.tld"); scheme, authority, path = self.parse_request_uri(uri) + if path is None: + self.simple_response("400 Bad Request", + "Invalid path in Request-URI.") + return False if NUMBER_SIGN in path: self.simple_response("400 Bad Request", "Illegal #fragment in Request-URI.") @@ -970,7 +969,7 @@ class HTTPRequest(object): self.rfile.read(remaining) if "date" not in hkeys: - self.outheaders.append(("Date", rfc822.formatdate())) + self.outheaders.append(("Date", email.utils.formatdate())) if "server" not in hkeys: self.outheaders.append(("Server", self.server.server_name)) @@ -1051,7 +1050,7 @@ class CP_fileobject(socket._fileobject): if size < 0: # Read until EOF # reset _rbuf. we consume it via buf. - self._rbuf = StringIO.StringIO() + self._rbuf = io.BytesIO() while True: data = self.recv(rbufsize) if not data: @@ -1066,12 +1065,12 @@ class CP_fileobject(socket._fileobject): # return. buf.seek(0) rv = buf.read(size) - self._rbuf = StringIO.StringIO() + self._rbuf = io.BytesIO() self._rbuf.write(buf.read()) return rv # reset _rbuf. we consume it via buf. - self._rbuf = StringIO.StringIO() + self._rbuf = io.BytesIO() while True: left = size - buf_len # recv() will malloc the amount of memory given as its @@ -1109,7 +1108,7 @@ class CP_fileobject(socket._fileobject): buf.seek(0) bline = buf.readline(size) if bline.endswith('\n') or len(bline) == size: - self._rbuf = StringIO.StringIO() + self._rbuf = io.BytesIO() self._rbuf.write(buf.read()) return bline del bline @@ -1120,7 +1119,7 @@ class CP_fileobject(socket._fileobject): buf.seek(0) buffers = [buf.read()] # reset _rbuf. we consume it via buf. - self._rbuf = StringIO.StringIO() + self._rbuf = io.BytesIO() data = None recv = self.recv while data != "\n": @@ -1132,7 +1131,7 @@ class CP_fileobject(socket._fileobject): buf.seek(0, 2) # seek end # reset _rbuf. we consume it via buf. - self._rbuf = StringIO.StringIO() + self._rbuf = io.BytesIO() while True: data = self.recv(self._rbufsize) if not data: @@ -1154,11 +1153,11 @@ class CP_fileobject(socket._fileobject): if buf_len >= size: buf.seek(0) rv = buf.read(size) - self._rbuf = StringIO.StringIO() + self._rbuf = io.BytesIO() self._rbuf.write(buf.read()) return rv # reset _rbuf. we consume it via buf. - self._rbuf = StringIO.StringIO() + self._rbuf = io.BytesIO() while True: data = self.recv(self._rbufsize) if not data: @@ -1757,7 +1756,7 @@ class HTTPServer(object): timeout = 10 """The timeout in seconds for accepted connections (default 10).""" - version = "CherryPy/3.8.0" + version = "CherryPy/5.1.0" """A version string for the HTTPServer.""" software = None @@ -1884,25 +1883,6 @@ class HTTPServer(object): if self.software is None: self.software = "%s Server" % self.version - # SSL backward compatibility - if (self.ssl_adapter is None and - getattr(self, 'ssl_certificate', None) and - getattr(self, 'ssl_private_key', None)): - warnings.warn( - "SSL attributes are deprecated in CherryPy 3.2, and will " - "be removed in CherryPy 3.3. Use an ssl_adapter attribute " - "instead.", - DeprecationWarning - ) - try: - from cherrypy.wsgiserver.ssl_pyopenssl import pyOpenSSLAdapter - except ImportError: - pass - else: - self.ssl_adapter = pyOpenSSLAdapter( - self.ssl_certificate, self.ssl_private_key, - getattr(self, 'ssl_certificate_chain', None)) - # Select the appropriate socket if isinstance(self.bind_addr, basestring): # AF_UNIX socket @@ -1915,7 +1895,7 @@ class HTTPServer(object): # So everyone can access the socket... try: - os.chmod(self.bind_addr, 511) # 0777 + os.chmod(self.bind_addr, 0o777) except: pass @@ -1984,7 +1964,7 @@ class HTTPServer(object): sys.stderr.write(msg + '\n') sys.stderr.flush() if traceback: - tblines = format_exc() + tblines = traceback_.format_exc() sys.stderr.write(tblines) sys.stderr.flush() @@ -2186,7 +2166,7 @@ ssl_adapters = { } -def get_ssl_adapter_class(name='pyopenssl'): +def get_ssl_adapter_class(name='builtin'): """Return an SSL adapter class for the given name.""" adapter = ssl_adapters[name.lower()] if isinstance(adapter, basestring): diff --git a/lib/cherrypy/wsgiserver/wsgiserver3.py b/lib/cherrypy/wsgiserver/wsgiserver3.py new file mode 100644 index 00000000..84df3a34 --- /dev/null +++ b/lib/cherrypy/wsgiserver/wsgiserver3.py @@ -0,0 +1,2187 @@ +"""A high-speed, production ready, thread pooled, generic HTTP server. + +Simplest example on how to use this module directly +(without using CherryPy's application machinery):: + + from cherrypy import wsgiserver + + def my_crazy_app(environ, start_response): + status = '200 OK' + response_headers = [('Content-type','text/plain')] + start_response(status, response_headers) + return ['Hello world!'] + + server = wsgiserver.CherryPyWSGIServer( + ('0.0.0.0', 8070), my_crazy_app, + server_name='www.cherrypy.example') + server.start() + +The CherryPy WSGI server can serve as many WSGI applications +as you want in one instance by using a WSGIPathInfoDispatcher:: + + d = WSGIPathInfoDispatcher({'/': my_crazy_app, '/blog': my_blog_app}) + server = wsgiserver.CherryPyWSGIServer(('0.0.0.0', 80), d) + +Want SSL support? Just set server.ssl_adapter to an SSLAdapter instance. + +This won't call the CherryPy engine (application side) at all, only the +HTTP server, which is independent from the rest of CherryPy. Don't +let the name "CherryPyWSGIServer" throw you; the name merely reflects +its origin, not its coupling. + +For those of you wanting to understand internals of this module, here's the +basic call flow. The server's listening thread runs a very tight loop, +sticking incoming connections onto a Queue:: + + server = CherryPyWSGIServer(...) + server.start() + while True: + tick() + # This blocks until a request comes in: + child = socket.accept() + conn = HTTPConnection(child, ...) + server.requests.put(conn) + +Worker threads are kept in a pool and poll the Queue, popping off and then +handling each connection in turn. Each connection can consist of an arbitrary +number of requests and their responses, so we run a nested loop:: + + while True: + conn = server.requests.get() + conn.communicate() + -> while True: + req = HTTPRequest(...) + req.parse_request() + -> # Read the Request-Line, e.g. "GET /page HTTP/1.1" + req.rfile.readline() + read_headers(req.rfile, req.inheaders) + req.respond() + -> response = app(...) + try: + for chunk in response: + if chunk: + req.write(chunk) + finally: + if hasattr(response, "close"): + response.close() + if req.close_connection: + return +""" + +__all__ = ['HTTPRequest', 'HTTPConnection', 'HTTPServer', + 'SizeCheckWrapper', 'KnownLengthRFile', 'ChunkedRFile', + 'CP_makefile', + 'MaxSizeExceeded', 'NoSSLError', 'FatalSSLAlert', + 'WorkerThread', 'ThreadPool', 'SSLAdapter', + 'CherryPyWSGIServer', + 'Gateway', 'WSGIGateway', 'WSGIGateway_10', 'WSGIGateway_u0', + 'WSGIPathInfoDispatcher', 'get_ssl_adapter_class', + 'socket_errors_to_ignore'] + +import os +try: + import queue +except: + import Queue as queue +import re +import email.utils +import socket +import sys +import threading +import time +import traceback as traceback_ +import errno +import logging +try: + # prefer slower Python-based io module + import _pyio as io +except ImportError: + # Python 2.6 + import io + + +if 'win' in sys.platform and hasattr(socket, "AF_INET6"): + if not hasattr(socket, 'IPPROTO_IPV6'): + socket.IPPROTO_IPV6 = 41 + if not hasattr(socket, 'IPV6_V6ONLY'): + socket.IPV6_V6ONLY = 27 + + +DEFAULT_BUFFER_SIZE = io.DEFAULT_BUFFER_SIZE + + +if sys.version_info >= (3, 0): + bytestr = bytes + unicodestr = str + basestring = (bytes, str) + + def ntob(n, encoding='ISO-8859-1'): + """Return the given native string as a byte string in the given + encoding. + """ + # In Python 3, the native string type is unicode + return n.encode(encoding) +else: + bytestr = str + unicodestr = unicode + basestring = basestring + + def ntob(n, encoding='ISO-8859-1'): + """Return the given native string as a byte string in the given + encoding. + """ + # In Python 2, the native string type is bytes. Assume it's already + # in the given encoding, which for ISO-8859-1 is almost always what + # was intended. + return n + +LF = ntob('\n') +CRLF = ntob('\r\n') +TAB = ntob('\t') +SPACE = ntob(' ') +COLON = ntob(':') +SEMICOLON = ntob(';') +EMPTY = ntob('') +NUMBER_SIGN = ntob('#') +QUESTION_MARK = ntob('?') +ASTERISK = ntob('*') +FORWARD_SLASH = ntob('/') +quoted_slash = re.compile(ntob("(?i)%2F")) + + +def plat_specific_errors(*errnames): + """Return error numbers for all errors in errnames on this platform. + + The 'errno' module contains different global constants depending on + the specific platform (OS). This function will return the list of + numeric values for a given list of potential names. + """ + errno_names = dir(errno) + nums = [getattr(errno, k) for k in errnames if k in errno_names] + # de-dupe the list + return list(dict.fromkeys(nums).keys()) + +socket_error_eintr = plat_specific_errors("EINTR", "WSAEINTR") + +socket_errors_to_ignore = plat_specific_errors( + "EPIPE", + "EBADF", "WSAEBADF", + "ENOTSOCK", "WSAENOTSOCK", + "ETIMEDOUT", "WSAETIMEDOUT", + "ECONNREFUSED", "WSAECONNREFUSED", + "ECONNRESET", "WSAECONNRESET", + "ECONNABORTED", "WSAECONNABORTED", + "ENETRESET", "WSAENETRESET", + "EHOSTDOWN", "EHOSTUNREACH", +) +socket_errors_to_ignore.append("timed out") +socket_errors_to_ignore.append("The read operation timed out") + +socket_errors_nonblocking = plat_specific_errors( + 'EAGAIN', 'EWOULDBLOCK', 'WSAEWOULDBLOCK') + +comma_separated_headers = [ + ntob(h) for h in + ['Accept', 'Accept-Charset', 'Accept-Encoding', + 'Accept-Language', 'Accept-Ranges', 'Allow', 'Cache-Control', + 'Connection', 'Content-Encoding', 'Content-Language', 'Expect', + 'If-Match', 'If-None-Match', 'Pragma', 'Proxy-Authenticate', 'TE', + 'Trailer', 'Transfer-Encoding', 'Upgrade', 'Vary', 'Via', 'Warning', + 'WWW-Authenticate'] +] + + +if not hasattr(logging, 'statistics'): + logging.statistics = {} + + +def read_headers(rfile, hdict=None): + """Read headers from the given stream into the given header dict. + + If hdict is None, a new header dict is created. Returns the populated + header dict. + + Headers which are repeated are folded together using a comma if their + specification so dictates. + + This function raises ValueError when the read bytes violate the HTTP spec. + You should probably return "400 Bad Request" if this happens. + """ + if hdict is None: + hdict = {} + + while True: + line = rfile.readline() + if not line: + # No more data--illegal end of headers + raise ValueError("Illegal end of headers.") + + if line == CRLF: + # Normal end of headers + break + if not line.endswith(CRLF): + raise ValueError("HTTP requires CRLF terminators") + + if line[0] in (SPACE, TAB): + # It's a continuation line. + v = line.strip() + else: + try: + k, v = line.split(COLON, 1) + except ValueError: + raise ValueError("Illegal header line.") + # TODO: what about TE and WWW-Authenticate? + k = k.strip().title() + v = v.strip() + hname = k + + if k in comma_separated_headers: + existing = hdict.get(hname) + if existing: + v = b", ".join((existing, v)) + hdict[hname] = v + + return hdict + + +class MaxSizeExceeded(Exception): + pass + + +class SizeCheckWrapper(object): + + """Wraps a file-like object, raising MaxSizeExceeded if too large.""" + + def __init__(self, rfile, maxlen): + self.rfile = rfile + self.maxlen = maxlen + self.bytes_read = 0 + + def _check_length(self): + if self.maxlen and self.bytes_read > self.maxlen: + raise MaxSizeExceeded() + + def read(self, size=None): + data = self.rfile.read(size) + self.bytes_read += len(data) + self._check_length() + return data + + def readline(self, size=None): + if size is not None: + data = self.rfile.readline(size) + self.bytes_read += len(data) + self._check_length() + return data + + # User didn't specify a size ... + # We read the line in chunks to make sure it's not a 100MB line ! + res = [] + while True: + data = self.rfile.readline(256) + self.bytes_read += len(data) + self._check_length() + res.append(data) + # See https://bitbucket.org/cherrypy/cherrypy/issue/421 + if len(data) < 256 or data[-1:] == LF: + return EMPTY.join(res) + + def readlines(self, sizehint=0): + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline() + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline() + return lines + + def close(self): + self.rfile.close() + + def __iter__(self): + return self + + def __next__(self): + data = next(self.rfile) + self.bytes_read += len(data) + self._check_length() + return data + + def next(self): + data = self.rfile.next() + self.bytes_read += len(data) + self._check_length() + return data + + +class KnownLengthRFile(object): + + """Wraps a file-like object, returning an empty string when exhausted.""" + + def __init__(self, rfile, content_length): + self.rfile = rfile + self.remaining = content_length + + def read(self, size=None): + if self.remaining == 0: + return b'' + if size is None: + size = self.remaining + else: + size = min(size, self.remaining) + + data = self.rfile.read(size) + self.remaining -= len(data) + return data + + def readline(self, size=None): + if self.remaining == 0: + return b'' + if size is None: + size = self.remaining + else: + size = min(size, self.remaining) + + data = self.rfile.readline(size) + self.remaining -= len(data) + return data + + def readlines(self, sizehint=0): + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline(sizehint) + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + return lines + + def close(self): + self.rfile.close() + + def __iter__(self): + return self + + def __next__(self): + data = next(self.rfile) + self.remaining -= len(data) + return data + + +class ChunkedRFile(object): + + """Wraps a file-like object, returning an empty string when exhausted. + + This class is intended to provide a conforming wsgi.input value for + request entities that have been encoded with the 'chunked' transfer + encoding. + """ + + def __init__(self, rfile, maxlen, bufsize=8192): + self.rfile = rfile + self.maxlen = maxlen + self.bytes_read = 0 + self.buffer = EMPTY + self.bufsize = bufsize + self.closed = False + + def _fetch(self): + if self.closed: + return + + line = self.rfile.readline() + self.bytes_read += len(line) + + if self.maxlen and self.bytes_read > self.maxlen: + raise MaxSizeExceeded("Request Entity Too Large", self.maxlen) + + line = line.strip().split(SEMICOLON, 1) + + try: + chunk_size = line.pop(0) + chunk_size = int(chunk_size, 16) + except ValueError: + raise ValueError("Bad chunked transfer size: " + repr(chunk_size)) + + if chunk_size <= 0: + self.closed = True + return + +## if line: chunk_extension = line[0] + + if self.maxlen and self.bytes_read + chunk_size > self.maxlen: + raise IOError("Request Entity Too Large") + + chunk = self.rfile.read(chunk_size) + self.bytes_read += len(chunk) + self.buffer += chunk + + crlf = self.rfile.read(2) + if crlf != CRLF: + raise ValueError( + "Bad chunked transfer coding (expected '\\r\\n', " + "got " + repr(crlf) + ")") + + def read(self, size=None): + data = EMPTY + while True: + if size and len(data) >= size: + return data + + if not self.buffer: + self._fetch() + if not self.buffer: + # EOF + return data + + if size: + remaining = size - len(data) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + data += self.buffer + + def readline(self, size=None): + data = EMPTY + while True: + if size and len(data) >= size: + return data + + if not self.buffer: + self._fetch() + if not self.buffer: + # EOF + return data + + newline_pos = self.buffer.find(LF) + if size: + if newline_pos == -1: + remaining = size - len(data) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + remaining = min(size - len(data), newline_pos) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + if newline_pos == -1: + data += self.buffer + else: + data += self.buffer[:newline_pos] + self.buffer = self.buffer[newline_pos:] + + def readlines(self, sizehint=0): + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline(sizehint) + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + return lines + + def read_trailer_lines(self): + if not self.closed: + raise ValueError( + "Cannot read trailers until the request body has been read.") + + while True: + line = self.rfile.readline() + if not line: + # No more data--illegal end of headers + raise ValueError("Illegal end of headers.") + + self.bytes_read += len(line) + if self.maxlen and self.bytes_read > self.maxlen: + raise IOError("Request Entity Too Large") + + if line == CRLF: + # Normal end of headers + break + if not line.endswith(CRLF): + raise ValueError("HTTP requires CRLF terminators") + + yield line + + def close(self): + self.rfile.close() + + def __iter__(self): + # Shamelessly stolen from StringIO + total = 0 + line = self.readline(sizehint) + while line: + yield line + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + + +class HTTPRequest(object): + + """An HTTP Request (and response). + + A single HTTP connection may consist of multiple request/response pairs. + """ + + server = None + """The HTTPServer object which is receiving this request.""" + + conn = None + """The HTTPConnection object on which this request connected.""" + + inheaders = {} + """A dict of request headers.""" + + outheaders = [] + """A list of header tuples to write in the response.""" + + ready = False + """When True, the request has been parsed and is ready to begin generating + the response. When False, signals the calling Connection that the response + should not be generated and the connection should close.""" + + close_connection = False + """Signals the calling Connection that the request should close. This does + not imply an error! The client and/or server may each request that the + connection be closed.""" + + chunked_write = False + """If True, output will be encoded with the "chunked" transfer-coding. + + This value is set automatically inside send_headers.""" + + def __init__(self, server, conn): + self.server = server + self.conn = conn + + self.ready = False + self.started_request = False + self.scheme = ntob("http") + if self.server.ssl_adapter is not None: + self.scheme = ntob("https") + # Use the lowest-common protocol in case read_request_line errors. + self.response_protocol = 'HTTP/1.0' + self.inheaders = {} + + self.status = "" + self.outheaders = [] + self.sent_headers = False + self.close_connection = self.__class__.close_connection + self.chunked_read = False + self.chunked_write = self.__class__.chunked_write + + def parse_request(self): + """Parse the next HTTP request start-line and message-headers.""" + self.rfile = SizeCheckWrapper(self.conn.rfile, + self.server.max_request_header_size) + try: + success = self.read_request_line() + except MaxSizeExceeded: + self.simple_response( + "414 Request-URI Too Long", + "The Request-URI sent with the request exceeds the maximum " + "allowed bytes.") + return + else: + if not success: + return + + try: + success = self.read_request_headers() + except MaxSizeExceeded: + self.simple_response( + "413 Request Entity Too Large", + "The headers sent with the request exceed the maximum " + "allowed bytes.") + return + else: + if not success: + return + + self.ready = True + + def read_request_line(self): + # HTTP/1.1 connections are persistent by default. If a client + # requests a page, then idles (leaves the connection open), + # then rfile.readline() will raise socket.error("timed out"). + # Note that it does this based on the value given to settimeout(), + # and doesn't need the client to request or acknowledge the close + # (although your TCP stack might suffer for it: cf Apache's history + # with FIN_WAIT_2). + request_line = self.rfile.readline() + + # Set started_request to True so communicate() knows to send 408 + # from here on out. + self.started_request = True + if not request_line: + return False + + if request_line == CRLF: + # RFC 2616 sec 4.1: "...if the server is reading the protocol + # stream at the beginning of a message and receives a CRLF + # first, it should ignore the CRLF." + # But only ignore one leading line! else we enable a DoS. + request_line = self.rfile.readline() + if not request_line: + return False + + if not request_line.endswith(CRLF): + self.simple_response( + "400 Bad Request", "HTTP requires CRLF terminators") + return False + + try: + method, uri, req_protocol = request_line.strip().split(SPACE, 2) + # The [x:y] slicing is necessary for byte strings to avoid getting + # ord's + rp = int(req_protocol[5:6]), int(req_protocol[7:8]) + except ValueError: + self.simple_response("400 Bad Request", "Malformed Request-Line") + return False + + self.uri = uri + self.method = method + + # uri may be an abs_path (including "http://host.domain.tld"); + scheme, authority, path = self.parse_request_uri(uri) + if path is None: + self.simple_response("400 Bad Request", + "Invalid path in Request-URI.") + return False + if NUMBER_SIGN in path: + self.simple_response("400 Bad Request", + "Illegal #fragment in Request-URI.") + return False + + if scheme: + self.scheme = scheme + + qs = EMPTY + if QUESTION_MARK in path: + path, qs = path.split(QUESTION_MARK, 1) + + # Unquote the path+params (e.g. "/this%20path" -> "/this path"). + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.1.2 + # + # But note that "...a URI must be separated into its components + # before the escaped characters within those components can be + # safely decoded." http://www.ietf.org/rfc/rfc2396.txt, sec 2.4.2 + # Therefore, "/this%2Fpath" becomes "/this%2Fpath", not "/this/path". + try: + atoms = [self.unquote_bytes(x) for x in quoted_slash.split(path)] + except ValueError: + ex = sys.exc_info()[1] + self.simple_response("400 Bad Request", ex.args[0]) + return False + path = b"%2F".join(atoms) + self.path = path + + # Note that, like wsgiref and most other HTTP servers, + # we "% HEX HEX"-unquote the path but not the query string. + self.qs = qs + + # Compare request and server HTTP protocol versions, in case our + # server does not support the requested protocol. Limit our output + # to min(req, server). We want the following output: + # request server actual written supported response + # protocol protocol response protocol feature set + # a 1.0 1.0 1.0 1.0 + # b 1.0 1.1 1.1 1.0 + # c 1.1 1.0 1.0 1.0 + # d 1.1 1.1 1.1 1.1 + # Notice that, in (b), the response will be "HTTP/1.1" even though + # the client only understands 1.0. RFC 2616 10.5.6 says we should + # only return 505 if the _major_ version is different. + # The [x:y] slicing is necessary for byte strings to avoid getting + # ord's + sp = int(self.server.protocol[5:6]), int(self.server.protocol[7:8]) + + if sp[0] != rp[0]: + self.simple_response("505 HTTP Version Not Supported") + return False + + self.request_protocol = req_protocol + self.response_protocol = "HTTP/%s.%s" % min(rp, sp) + return True + + def read_request_headers(self): + """Read self.rfile into self.inheaders. Return success.""" + + # then all the http headers + try: + read_headers(self.rfile, self.inheaders) + except ValueError: + ex = sys.exc_info()[1] + self.simple_response("400 Bad Request", ex.args[0]) + return False + + mrbs = self.server.max_request_body_size + if mrbs and int(self.inheaders.get(b"Content-Length", 0)) > mrbs: + self.simple_response( + "413 Request Entity Too Large", + "The entity sent with the request exceeds the maximum " + "allowed bytes.") + return False + + # Persistent connection support + if self.response_protocol == "HTTP/1.1": + # Both server and client are HTTP/1.1 + if self.inheaders.get(b"Connection", b"") == b"close": + self.close_connection = True + else: + # Either the server or client (or both) are HTTP/1.0 + if self.inheaders.get(b"Connection", b"") != b"Keep-Alive": + self.close_connection = True + + # Transfer-Encoding support + te = None + if self.response_protocol == "HTTP/1.1": + te = self.inheaders.get(b"Transfer-Encoding") + if te: + te = [x.strip().lower() for x in te.split(b",") if x.strip()] + + self.chunked_read = False + + if te: + for enc in te: + if enc == b"chunked": + self.chunked_read = True + else: + # Note that, even if we see "chunked", we must reject + # if there is an extension we don't recognize. + self.simple_response("501 Unimplemented") + self.close_connection = True + return False + + # From PEP 333: + # "Servers and gateways that implement HTTP 1.1 must provide + # transparent support for HTTP 1.1's "expect/continue" mechanism. + # This may be done in any of several ways: + # 1. Respond to requests containing an Expect: 100-continue request + # with an immediate "100 Continue" response, and proceed normally. + # 2. Proceed with the request normally, but provide the application + # with a wsgi.input stream that will send the "100 Continue" + # response if/when the application first attempts to read from + # the input stream. The read request must then remain blocked + # until the client responds. + # 3. Wait until the client decides that the server does not support + # expect/continue, and sends the request body on its own. + # (This is suboptimal, and is not recommended.) + # + # We used to do 3, but are now doing 1. Maybe we'll do 2 someday, + # but it seems like it would be a big slowdown for such a rare case. + if self.inheaders.get(b"Expect", b"") == b"100-continue": + # Don't use simple_response here, because it emits headers + # we don't want. See + # https://bitbucket.org/cherrypy/cherrypy/issue/951 + msg = self.server.protocol.encode( + 'ascii') + b" 100 Continue\r\n\r\n" + try: + self.conn.wfile.write(msg) + except socket.error: + x = sys.exc_info()[1] + if x.args[0] not in socket_errors_to_ignore: + raise + return True + + def parse_request_uri(self, uri): + """Parse a Request-URI into (scheme, authority, path). + + Note that Request-URI's must be one of:: + + Request-URI = "*" | absoluteURI | abs_path | authority + + Therefore, a Request-URI which starts with a double forward-slash + cannot be a "net_path":: + + net_path = "//" authority [ abs_path ] + + Instead, it must be interpreted as an "abs_path" with an empty first + path segment:: + + abs_path = "/" path_segments + path_segments = segment *( "/" segment ) + segment = *pchar *( ";" param ) + param = *pchar + """ + if uri == ASTERISK: + return None, None, uri + + scheme, sep, remainder = uri.partition(b'://') + if sep and QUESTION_MARK not in scheme: + # An absoluteURI. + # If there's a scheme (and it must be http or https), then: + # http_URL = "http:" "//" host [ ":" port ] [ abs_path [ "?" query + # ]] + authority, path_a, path_b = remainder.partition(FORWARD_SLASH) + return scheme.lower(), authority, path_a + path_b + + if uri.startswith(FORWARD_SLASH): + # An abs_path. + return None, None, uri + else: + # An authority. + return None, uri, None + + def unquote_bytes(self, path): + """takes quoted string and unquotes % encoded values""" + res = path.split(b'%') + + for i in range(1, len(res)): + item = res[i] + try: + res[i] = bytes([int(item[:2], 16)]) + item[2:] + except ValueError: + raise + return b''.join(res) + + def respond(self): + """Call the gateway and write its iterable output.""" + mrbs = self.server.max_request_body_size + if self.chunked_read: + self.rfile = ChunkedRFile(self.conn.rfile, mrbs) + else: + cl = int(self.inheaders.get(b"Content-Length", 0)) + if mrbs and mrbs < cl: + if not self.sent_headers: + self.simple_response( + "413 Request Entity Too Large", + "The entity sent with the request exceeds the " + "maximum allowed bytes.") + return + self.rfile = KnownLengthRFile(self.conn.rfile, cl) + + self.server.gateway(self).respond() + + if (self.ready and not self.sent_headers): + self.sent_headers = True + self.send_headers() + if self.chunked_write: + self.conn.wfile.write(b"0\r\n\r\n") + + def simple_response(self, status, msg=""): + """Write a simple response back to the client.""" + status = str(status) + buf = [bytes(self.server.protocol, "ascii") + SPACE + + bytes(status, "ISO-8859-1") + CRLF, + bytes("Content-Length: %s\r\n" % len(msg), "ISO-8859-1"), + b"Content-Type: text/plain\r\n"] + + if status[:3] in ("413", "414"): + # Request Entity Too Large / Request-URI Too Long + self.close_connection = True + if self.response_protocol == 'HTTP/1.1': + # This will not be true for 414, since read_request_line + # usually raises 414 before reading the whole line, and we + # therefore cannot know the proper response_protocol. + buf.append(b"Connection: close\r\n") + else: + # HTTP/1.0 had no 413/414 status nor Connection header. + # Emit 400 instead and trust the message body is enough. + status = "400 Bad Request" + + buf.append(CRLF) + if msg: + if isinstance(msg, unicodestr): + msg = msg.encode("ISO-8859-1") + buf.append(msg) + + try: + self.conn.wfile.write(b"".join(buf)) + except socket.error: + x = sys.exc_info()[1] + if x.args[0] not in socket_errors_to_ignore: + raise + + def write(self, chunk): + """Write unbuffered data to the client.""" + if self.chunked_write and chunk: + buf = [bytes(hex(len(chunk)), 'ASCII')[2:], CRLF, chunk, CRLF] + self.conn.wfile.write(EMPTY.join(buf)) + else: + self.conn.wfile.write(chunk) + + def send_headers(self): + """Assert, process, and send the HTTP response message-headers. + + You must set self.status, and self.outheaders before calling this. + """ + hkeys = [key.lower() for key, value in self.outheaders] + status = int(self.status[:3]) + + if status == 413: + # Request Entity Too Large. Close conn to avoid garbage. + self.close_connection = True + elif b"content-length" not in hkeys: + # "All 1xx (informational), 204 (no content), + # and 304 (not modified) responses MUST NOT + # include a message-body." So no point chunking. + if status < 200 or status in (204, 205, 304): + pass + else: + if (self.response_protocol == 'HTTP/1.1' + and self.method != b'HEAD'): + # Use the chunked transfer-coding + self.chunked_write = True + self.outheaders.append((b"Transfer-Encoding", b"chunked")) + else: + # Closing the conn is the only way to determine len. + self.close_connection = True + + if b"connection" not in hkeys: + if self.response_protocol == 'HTTP/1.1': + # Both server and client are HTTP/1.1 or better + if self.close_connection: + self.outheaders.append((b"Connection", b"close")) + else: + # Server and/or client are HTTP/1.0 + if not self.close_connection: + self.outheaders.append((b"Connection", b"Keep-Alive")) + + if (not self.close_connection) and (not self.chunked_read): + # Read any remaining request body data on the socket. + # "If an origin server receives a request that does not include an + # Expect request-header field with the "100-continue" expectation, + # the request includes a request body, and the server responds + # with a final status code before reading the entire request body + # from the transport connection, then the server SHOULD NOT close + # the transport connection until it has read the entire request, + # or until the client closes the connection. Otherwise, the client + # might not reliably receive the response message. However, this + # requirement is not be construed as preventing a server from + # defending itself against denial-of-service attacks, or from + # badly broken client implementations." + remaining = getattr(self.rfile, 'remaining', 0) + if remaining > 0: + self.rfile.read(remaining) + + if b"date" not in hkeys: + self.outheaders.append(( + b"Date", + email.utils.formatdate(usegmt=True).encode('ISO-8859-1') + )) + + if b"server" not in hkeys: + self.outheaders.append( + (b"Server", self.server.server_name.encode('ISO-8859-1'))) + + buf = [self.server.protocol.encode( + 'ascii') + SPACE + self.status + CRLF] + for k, v in self.outheaders: + buf.append(k + COLON + SPACE + v + CRLF) + buf.append(CRLF) + self.conn.wfile.write(EMPTY.join(buf)) + + +class NoSSLError(Exception): + + """Exception raised when a client speaks HTTP to an HTTPS socket.""" + pass + + +class FatalSSLAlert(Exception): + + """Exception raised when the SSL implementation signals a fatal alert.""" + pass + + +class CP_BufferedWriter(io.BufferedWriter): + + """Faux file object attached to a socket object.""" + + def write(self, b): + self._checkClosed() + if isinstance(b, str): + raise TypeError("can't write str to binary stream") + + with self._write_lock: + self._write_buf.extend(b) + self._flush_unlocked() + return len(b) + + def _flush_unlocked(self): + self._checkClosed("flush of closed file") + while self._write_buf: + try: + # ssl sockets only except 'bytes', not bytearrays + # so perhaps we should conditionally wrap this for perf? + n = self.raw.write(bytes(self._write_buf)) + except io.BlockingIOError as e: + n = e.characters_written + del self._write_buf[:n] + + +def CP_makefile(sock, mode='r', bufsize=DEFAULT_BUFFER_SIZE): + if 'r' in mode: + return io.BufferedReader(socket.SocketIO(sock, mode), bufsize) + else: + return CP_BufferedWriter(socket.SocketIO(sock, mode), bufsize) + + +class HTTPConnection(object): + + """An HTTP connection (active socket). + + server: the Server object which received this connection. + socket: the raw socket object (usually TCP) for this connection. + makefile: a fileobject class for reading from the socket. + """ + + remote_addr = None + remote_port = None + ssl_env = None + rbufsize = DEFAULT_BUFFER_SIZE + wbufsize = DEFAULT_BUFFER_SIZE + RequestHandlerClass = HTTPRequest + + def __init__(self, server, sock, makefile=CP_makefile): + self.server = server + self.socket = sock + self.rfile = makefile(sock, "rb", self.rbufsize) + self.wfile = makefile(sock, "wb", self.wbufsize) + self.requests_seen = 0 + + def communicate(self): + """Read each request and respond appropriately.""" + request_seen = False + try: + while True: + # (re)set req to None so that if something goes wrong in + # the RequestHandlerClass constructor, the error doesn't + # get written to the previous request. + req = None + req = self.RequestHandlerClass(self.server, self) + + # This order of operations should guarantee correct pipelining. + req.parse_request() + if self.server.stats['Enabled']: + self.requests_seen += 1 + if not req.ready: + # Something went wrong in the parsing (and the server has + # probably already made a simple_response). Return and + # let the conn close. + return + + request_seen = True + req.respond() + if req.close_connection: + return + except socket.error: + e = sys.exc_info()[1] + errnum = e.args[0] + # sadly SSL sockets return a different (longer) time out string + if ( + errnum == 'timed out' or + errnum == 'The read operation timed out' + ): + # Don't error if we're between requests; only error + # if 1) no request has been started at all, or 2) we're + # in the middle of a request. + # See https://bitbucket.org/cherrypy/cherrypy/issue/853 + if (not request_seen) or (req and req.started_request): + # Don't bother writing the 408 if the response + # has already started being written. + if req and not req.sent_headers: + try: + req.simple_response("408 Request Timeout") + except FatalSSLAlert: + # Close the connection. + return + elif errnum not in socket_errors_to_ignore: + self.server.error_log("socket.error %s" % repr(errnum), + level=logging.WARNING, traceback=True) + if req and not req.sent_headers: + try: + req.simple_response("500 Internal Server Error") + except FatalSSLAlert: + # Close the connection. + return + return + except (KeyboardInterrupt, SystemExit): + raise + except FatalSSLAlert: + # Close the connection. + return + except NoSSLError: + if req and not req.sent_headers: + # Unwrap our wfile + self.wfile = CP_makefile( + self.socket._sock, "wb", self.wbufsize) + req.simple_response( + "400 Bad Request", + "The client sent a plain HTTP request, but this server " + "only speaks HTTPS on this port.") + self.linger = True + except Exception: + e = sys.exc_info()[1] + self.server.error_log(repr(e), level=logging.ERROR, traceback=True) + if req and not req.sent_headers: + try: + req.simple_response("500 Internal Server Error") + except FatalSSLAlert: + # Close the connection. + return + + linger = False + + def close(self): + """Close the socket underlying this connection.""" + self.rfile.close() + + if not self.linger: + # Python's socket module does NOT call close on the kernel + # socket when you call socket.close(). We do so manually here + # because we want this server to send a FIN TCP segment + # immediately. Note this must be called *before* calling + # socket.close(), because the latter drops its reference to + # the kernel socket. + # Python 3 *probably* fixed this with socket._real_close; + # hard to tell. +# self.socket._sock.close() + self.socket.close() + else: + # On the other hand, sometimes we want to hang around for a bit + # to make sure the client has a chance to read our entire + # response. Skipping the close() calls here delays the FIN + # packet until the socket object is garbage-collected later. + # Someday, perhaps, we'll do the full lingering_close that + # Apache does, but not today. + pass + + +class TrueyZero(object): + + """An object which equals and does math like the integer 0 but evals True. + """ + + def __add__(self, other): + return other + + def __radd__(self, other): + return other +trueyzero = TrueyZero() + + +_SHUTDOWNREQUEST = None + + +class WorkerThread(threading.Thread): + + """Thread which continuously polls a Queue for Connection objects. + + Due to the timing issues of polling a Queue, a WorkerThread does not + check its own 'ready' flag after it has started. To stop the thread, + it is necessary to stick a _SHUTDOWNREQUEST object onto the Queue + (one for each running WorkerThread). + """ + + conn = None + """The current connection pulled off the Queue, or None.""" + + server = None + """The HTTP Server which spawned this thread, and which owns the + Queue and is placing active connections into it.""" + + ready = False + """A simple flag for the calling server to know when this thread + has begun polling the Queue.""" + + def __init__(self, server): + self.ready = False + self.server = server + + self.requests_seen = 0 + self.bytes_read = 0 + self.bytes_written = 0 + self.start_time = None + self.work_time = 0 + self.stats = { + 'Requests': lambda s: self.requests_seen + ( + (self.start_time is None) and + trueyzero or + self.conn.requests_seen + ), + 'Bytes Read': lambda s: self.bytes_read + ( + (self.start_time is None) and + trueyzero or + self.conn.rfile.bytes_read + ), + 'Bytes Written': lambda s: self.bytes_written + ( + (self.start_time is None) and + trueyzero or + self.conn.wfile.bytes_written + ), + 'Work Time': lambda s: self.work_time + ( + (self.start_time is None) and + trueyzero or + time.time() - self.start_time + ), + 'Read Throughput': lambda s: s['Bytes Read'](s) / ( + s['Work Time'](s) or 1e-6), + 'Write Throughput': lambda s: s['Bytes Written'](s) / ( + s['Work Time'](s) or 1e-6), + } + threading.Thread.__init__(self) + + def run(self): + self.server.stats['Worker Threads'][self.getName()] = self.stats + try: + self.ready = True + while True: + conn = self.server.requests.get() + if conn is _SHUTDOWNREQUEST: + return + + self.conn = conn + if self.server.stats['Enabled']: + self.start_time = time.time() + try: + conn.communicate() + finally: + conn.close() + if self.server.stats['Enabled']: + self.requests_seen += self.conn.requests_seen + self.bytes_read += self.conn.rfile.bytes_read + self.bytes_written += self.conn.wfile.bytes_written + self.work_time += time.time() - self.start_time + self.start_time = None + self.conn = None + except (KeyboardInterrupt, SystemExit): + exc = sys.exc_info()[1] + self.server.interrupt = exc + + +class ThreadPool(object): + + """A Request Queue for an HTTPServer which pools threads. + + ThreadPool objects must provide min, get(), put(obj), start() + and stop(timeout) attributes. + """ + + def __init__(self, server, min=10, max=-1, + accepted_queue_size=-1, accepted_queue_timeout=10): + self.server = server + self.min = min + self.max = max + self._threads = [] + self._queue = queue.Queue(maxsize=accepted_queue_size) + self._queue_put_timeout = accepted_queue_timeout + self.get = self._queue.get + + def start(self): + """Start the pool of threads.""" + for i in range(self.min): + self._threads.append(WorkerThread(self.server)) + for worker in self._threads: + worker.setName("CP Server " + worker.getName()) + worker.start() + for worker in self._threads: + while not worker.ready: + time.sleep(.1) + + def _get_idle(self): + """Number of worker threads which are idle. Read-only.""" + return len([t for t in self._threads if t.conn is None]) + idle = property(_get_idle, doc=_get_idle.__doc__) + + def put(self, obj): + self._queue.put(obj, block=True, timeout=self._queue_put_timeout) + if obj is _SHUTDOWNREQUEST: + return + + def grow(self, amount): + """Spawn new worker threads (not above self.max).""" + if self.max > 0: + budget = max(self.max - len(self._threads), 0) + else: + # self.max <= 0 indicates no maximum + budget = float('inf') + + n_new = min(amount, budget) + + workers = [self._spawn_worker() for i in range(n_new)] + while not all(worker.ready for worker in workers): + time.sleep(.1) + self._threads.extend(workers) + + def _spawn_worker(self): + worker = WorkerThread(self.server) + worker.setName("CP Server " + worker.getName()) + worker.start() + return worker + + def shrink(self, amount): + """Kill off worker threads (not below self.min).""" + # Grow/shrink the pool if necessary. + # Remove any dead threads from our list + for t in self._threads: + if not t.isAlive(): + self._threads.remove(t) + amount -= 1 + + # calculate the number of threads above the minimum + n_extra = max(len(self._threads) - self.min, 0) + + # don't remove more than amount + n_to_remove = min(amount, n_extra) + + # put shutdown requests on the queue equal to the number of threads + # to remove. As each request is processed by a worker, that worker + # will terminate and be culled from the list. + for n in range(n_to_remove): + self._queue.put(_SHUTDOWNREQUEST) + + def stop(self, timeout=5): + # Must shut down threads here so the code that calls + # this method can know when all threads are stopped. + for worker in self._threads: + self._queue.put(_SHUTDOWNREQUEST) + + # Don't join currentThread (when stop is called inside a request). + current = threading.currentThread() + if timeout and timeout >= 0: + endtime = time.time() + timeout + while self._threads: + worker = self._threads.pop() + if worker is not current and worker.isAlive(): + try: + if timeout is None or timeout < 0: + worker.join() + else: + remaining_time = endtime - time.time() + if remaining_time > 0: + worker.join(remaining_time) + if worker.isAlive(): + # We exhausted the timeout. + # Forcibly shut down the socket. + c = worker.conn + if c and not c.rfile.closed: + try: + c.socket.shutdown(socket.SHUT_RD) + except TypeError: + # pyOpenSSL sockets don't take an arg + c.socket.shutdown() + worker.join() + except (AssertionError, + # Ignore repeated Ctrl-C. + # See + # https://bitbucket.org/cherrypy/cherrypy/issue/691. + KeyboardInterrupt): + pass + + def _get_qsize(self): + return self._queue.qsize() + qsize = property(_get_qsize) + + +try: + import fcntl +except ImportError: + try: + from ctypes import windll, WinError + import ctypes.wintypes + _SetHandleInformation = windll.kernel32.SetHandleInformation + _SetHandleInformation.argtypes = [ + ctypes.wintypes.HANDLE, + ctypes.wintypes.DWORD, + ctypes.wintypes.DWORD, + ] + _SetHandleInformation.restype = ctypes.wintypes.BOOL + except ImportError: + def prevent_socket_inheritance(sock): + """Dummy function, since neither fcntl nor ctypes are available.""" + pass + else: + def prevent_socket_inheritance(sock): + """Mark the given socket fd as non-inheritable (Windows).""" + if not _SetHandleInformation(sock.fileno(), 1, 0): + raise WinError() +else: + def prevent_socket_inheritance(sock): + """Mark the given socket fd as non-inheritable (POSIX).""" + fd = sock.fileno() + old_flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, old_flags | fcntl.FD_CLOEXEC) + + +class SSLAdapter(object): + + """Base class for SSL driver library adapters. + + Required methods: + + * ``wrap(sock) -> (wrapped socket, ssl environ dict)`` + * ``makefile(sock, mode='r', bufsize=DEFAULT_BUFFER_SIZE) -> + socket file object`` + """ + + def __init__(self, certificate, private_key, certificate_chain=None): + self.certificate = certificate + self.private_key = private_key + self.certificate_chain = certificate_chain + + def wrap(self, sock): + raise NotImplemented + + def makefile(self, sock, mode='r', bufsize=DEFAULT_BUFFER_SIZE): + raise NotImplemented + + +class HTTPServer(object): + + """An HTTP server.""" + + _bind_addr = "127.0.0.1" + _interrupt = None + + gateway = None + """A Gateway instance.""" + + minthreads = None + """The minimum number of worker threads to create (default 10).""" + + maxthreads = None + """The maximum number of worker threads to create (default -1 = no limit). + """ + + server_name = None + """The name of the server; defaults to socket.gethostname().""" + + protocol = "HTTP/1.1" + """The version string to write in the Status-Line of all HTTP responses. + + For example, "HTTP/1.1" is the default. This also limits the supported + features used in the response.""" + + request_queue_size = 5 + """The 'backlog' arg to socket.listen(); max queued connections + (default 5). + """ + + shutdown_timeout = 5 + """The total time, in seconds, to wait for worker threads to cleanly exit. + """ + + timeout = 10 + """The timeout in seconds for accepted connections (default 10).""" + + version = "CherryPy/5.1.0" + """A version string for the HTTPServer.""" + + software = None + """The value to set for the SERVER_SOFTWARE entry in the WSGI environ. + + If None, this defaults to ``'%s Server' % self.version``.""" + + ready = False + """An internal flag which marks whether the socket is accepting + connections. + """ + + max_request_header_size = 0 + """The maximum size, in bytes, for request headers, or 0 for no limit.""" + + max_request_body_size = 0 + """The maximum size, in bytes, for request bodies, or 0 for no limit.""" + + nodelay = True + """If True (the default since 3.1), sets the TCP_NODELAY socket option.""" + + ConnectionClass = HTTPConnection + """The class to use for handling HTTP connections.""" + + ssl_adapter = None + """An instance of SSLAdapter (or a subclass). + + You must have the corresponding SSL driver library installed.""" + + def __init__(self, bind_addr, gateway, minthreads=10, maxthreads=-1, + server_name=None): + self.bind_addr = bind_addr + self.gateway = gateway + + self.requests = ThreadPool(self, min=minthreads or 1, max=maxthreads) + + if not server_name: + server_name = socket.gethostname() + self.server_name = server_name + self.clear_stats() + + def clear_stats(self): + self._start_time = None + self._run_time = 0 + self.stats = { + 'Enabled': False, + 'Bind Address': lambda s: repr(self.bind_addr), + 'Run time': lambda s: (not s['Enabled']) and -1 or self.runtime(), + 'Accepts': 0, + 'Accepts/sec': lambda s: s['Accepts'] / self.runtime(), + 'Queue': lambda s: getattr(self.requests, "qsize", None), + 'Threads': lambda s: len(getattr(self.requests, "_threads", [])), + 'Threads Idle': lambda s: getattr(self.requests, "idle", None), + 'Socket Errors': 0, + 'Requests': lambda s: (not s['Enabled']) and -1 or sum( + [w['Requests'](w) for w in s['Worker Threads'].values()], 0), + 'Bytes Read': lambda s: (not s['Enabled']) and -1 or sum( + [w['Bytes Read'](w) for w in s['Worker Threads'].values()], 0), + 'Bytes Written': lambda s: (not s['Enabled']) and -1 or sum( + [w['Bytes Written'](w) for w in s['Worker Threads'].values()], + 0), + 'Work Time': lambda s: (not s['Enabled']) and -1 or sum( + [w['Work Time'](w) for w in s['Worker Threads'].values()], 0), + 'Read Throughput': lambda s: (not s['Enabled']) and -1 or sum( + [w['Bytes Read'](w) / (w['Work Time'](w) or 1e-6) + for w in s['Worker Threads'].values()], 0), + 'Write Throughput': lambda s: (not s['Enabled']) and -1 or sum( + [w['Bytes Written'](w) / (w['Work Time'](w) or 1e-6) + for w in s['Worker Threads'].values()], 0), + 'Worker Threads': {}, + } + logging.statistics["CherryPy HTTPServer %d" % id(self)] = self.stats + + def runtime(self): + if self._start_time is None: + return self._run_time + else: + return self._run_time + (time.time() - self._start_time) + + def __str__(self): + return "%s.%s(%r)" % (self.__module__, self.__class__.__name__, + self.bind_addr) + + def _get_bind_addr(self): + return self._bind_addr + + def _set_bind_addr(self, value): + if isinstance(value, tuple) and value[0] in ('', None): + # Despite the socket module docs, using '' does not + # allow AI_PASSIVE to work. Passing None instead + # returns '0.0.0.0' like we want. In other words: + # host AI_PASSIVE result + # '' Y 192.168.x.y + # '' N 192.168.x.y + # None Y 0.0.0.0 + # None N 127.0.0.1 + # But since you can get the same effect with an explicit + # '0.0.0.0', we deny both the empty string and None as values. + raise ValueError("Host values of '' or None are not allowed. " + "Use '0.0.0.0' (IPv4) or '::' (IPv6) instead " + "to listen on all active interfaces.") + self._bind_addr = value + bind_addr = property( + _get_bind_addr, + _set_bind_addr, + doc="""The interface on which to listen for connections. + + For TCP sockets, a (host, port) tuple. Host values may be any IPv4 + or IPv6 address, or any valid hostname. The string 'localhost' is a + synonym for '127.0.0.1' (or '::1', if your hosts file prefers IPv6). + The string '0.0.0.0' is a special IPv4 entry meaning "any active + interface" (INADDR_ANY), and '::' is the similar IN6ADDR_ANY for + IPv6. The empty string or None are not allowed. + + For UNIX sockets, supply the filename as a string.""") + + def start(self): + """Run the server forever.""" + # We don't have to trap KeyboardInterrupt or SystemExit here, + # because cherrpy.server already does so, calling self.stop() for us. + # If you're using this server with another framework, you should + # trap those exceptions in whatever code block calls start(). + self._interrupt = None + + if self.software is None: + self.software = "%s Server" % self.version + + # Select the appropriate socket + if isinstance(self.bind_addr, basestring): + # AF_UNIX socket + + # So we can reuse the socket... + try: + os.unlink(self.bind_addr) + except: + pass + + # So everyone can access the socket... + try: + os.chmod(self.bind_addr, 0o777) + except: + pass + + info = [ + (socket.AF_UNIX, socket.SOCK_STREAM, 0, "", self.bind_addr)] + else: + # AF_INET or AF_INET6 socket + # Get the correct address family for our host (allows IPv6 + # addresses) + host, port = self.bind_addr + try: + info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM, 0, + socket.AI_PASSIVE) + except socket.gaierror: + if ':' in self.bind_addr[0]: + info = [(socket.AF_INET6, socket.SOCK_STREAM, + 0, "", self.bind_addr + (0, 0))] + else: + info = [(socket.AF_INET, socket.SOCK_STREAM, + 0, "", self.bind_addr)] + + self.socket = None + msg = "No socket could be created" + for res in info: + af, socktype, proto, canonname, sa = res + try: + self.bind(af, socktype, proto) + except socket.error as serr: + msg = "%s -- (%s: %s)" % (msg, sa, serr) + if self.socket: + self.socket.close() + self.socket = None + continue + break + if not self.socket: + raise socket.error(msg) + + # Timeout so KeyboardInterrupt can be caught on Win32 + self.socket.settimeout(1) + self.socket.listen(self.request_queue_size) + + # Create worker threads + self.requests.start() + + self.ready = True + self._start_time = time.time() + while self.ready: + try: + self.tick() + except (KeyboardInterrupt, SystemExit): + raise + except: + self.error_log("Error in HTTPServer.tick", level=logging.ERROR, + traceback=True) + if self.interrupt: + while self.interrupt is True: + # Wait for self.stop() to complete. See _set_interrupt. + time.sleep(0.1) + if self.interrupt: + raise self.interrupt + + def error_log(self, msg="", level=20, traceback=False): + # Override this in subclasses as desired + sys.stderr.write(msg + '\n') + sys.stderr.flush() + if traceback: + tblines = traceback_.format_exc() + sys.stderr.write(tblines) + sys.stderr.flush() + + def bind(self, family, type, proto=0): + """Create (or recreate) the actual socket object.""" + self.socket = socket.socket(family, type, proto) + prevent_socket_inheritance(self.socket) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if self.nodelay and not isinstance(self.bind_addr, str): + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + if self.ssl_adapter is not None: + self.socket = self.ssl_adapter.bind(self.socket) + + # If listening on the IPV6 any address ('::' = IN6ADDR_ANY), + # activate dual-stack. See + # https://bitbucket.org/cherrypy/cherrypy/issue/871. + if (hasattr(socket, 'AF_INET6') and family == socket.AF_INET6 + and self.bind_addr[0] in ('::', '::0', '::0.0.0.0')): + try: + self.socket.setsockopt( + socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + except (AttributeError, socket.error): + # Apparently, the socket option is not available in + # this machine's TCP stack + pass + + self.socket.bind(self.bind_addr) + + def tick(self): + """Accept a new connection and put it on the Queue.""" + try: + s, addr = self.socket.accept() + if self.stats['Enabled']: + self.stats['Accepts'] += 1 + if not self.ready: + return + + prevent_socket_inheritance(s) + if hasattr(s, 'settimeout'): + s.settimeout(self.timeout) + + makefile = CP_makefile + ssl_env = {} + # if ssl cert and key are set, we try to be a secure HTTP server + if self.ssl_adapter is not None: + try: + s, ssl_env = self.ssl_adapter.wrap(s) + except NoSSLError: + msg = ("The client sent a plain HTTP request, but " + "this server only speaks HTTPS on this port.") + buf = ["%s 400 Bad Request\r\n" % self.protocol, + "Content-Length: %s\r\n" % len(msg), + "Content-Type: text/plain\r\n\r\n", + msg] + + wfile = makefile(s, "wb", DEFAULT_BUFFER_SIZE) + try: + wfile.write("".join(buf).encode('ISO-8859-1')) + except socket.error: + x = sys.exc_info()[1] + if x.args[0] not in socket_errors_to_ignore: + raise + return + if not s: + return + makefile = self.ssl_adapter.makefile + # Re-apply our timeout since we may have a new socket object + if hasattr(s, 'settimeout'): + s.settimeout(self.timeout) + + conn = self.ConnectionClass(self, s, makefile) + + if not isinstance(self.bind_addr, basestring): + # optional values + # Until we do DNS lookups, omit REMOTE_HOST + if addr is None: # sometimes this can happen + # figure out if AF_INET or AF_INET6. + if len(s.getsockname()) == 2: + # AF_INET + addr = ('0.0.0.0', 0) + else: + # AF_INET6 + addr = ('::', 0) + conn.remote_addr = addr[0] + conn.remote_port = addr[1] + + conn.ssl_env = ssl_env + + try: + self.requests.put(conn) + except queue.Full: + # Just drop the conn. TODO: write 503 back? + conn.close() + return + except socket.timeout: + # The only reason for the timeout in start() is so we can + # notice keyboard interrupts on Win32, which don't interrupt + # accept() by default + return + except socket.error: + x = sys.exc_info()[1] + if self.stats['Enabled']: + self.stats['Socket Errors'] += 1 + if x.args[0] in socket_error_eintr: + # I *think* this is right. EINTR should occur when a signal + # is received during the accept() call; all docs say retry + # the call, and I *think* I'm reading it right that Python + # will then go ahead and poll for and handle the signal + # elsewhere. See + # https://bitbucket.org/cherrypy/cherrypy/issue/707. + return + if x.args[0] in socket_errors_nonblocking: + # Just try again. See + # https://bitbucket.org/cherrypy/cherrypy/issue/479. + return + if x.args[0] in socket_errors_to_ignore: + # Our socket was closed. + # See https://bitbucket.org/cherrypy/cherrypy/issue/686. + return + raise + + def _get_interrupt(self): + return self._interrupt + + def _set_interrupt(self, interrupt): + self._interrupt = True + self.stop() + self._interrupt = interrupt + interrupt = property(_get_interrupt, _set_interrupt, + doc="Set this to an Exception instance to " + "interrupt the server.") + + def stop(self): + """Gracefully shutdown a server that is serving forever.""" + self.ready = False + if self._start_time is not None: + self._run_time += (time.time() - self._start_time) + self._start_time = None + + sock = getattr(self, "socket", None) + if sock: + if not isinstance(self.bind_addr, basestring): + # Touch our own socket to make accept() return immediately. + try: + host, port = sock.getsockname()[:2] + except socket.error: + x = sys.exc_info()[1] + if x.args[0] not in socket_errors_to_ignore: + # Changed to use error code and not message + # See + # https://bitbucket.org/cherrypy/cherrypy/issue/860. + raise + else: + # Note that we're explicitly NOT using AI_PASSIVE, + # here, because we want an actual IP to touch. + # localhost won't work if we've bound to a public IP, + # but it will if we bound to '0.0.0.0' (INADDR_ANY). + for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + s = None + try: + s = socket.socket(af, socktype, proto) + # See + # http://groups.google.com/group/cherrypy-users/ + # browse_frm/thread/bbfe5eb39c904fe0 + s.settimeout(1.0) + s.connect((host, port)) + s.close() + except socket.error: + if s: + s.close() + if hasattr(sock, "close"): + sock.close() + self.socket = None + + self.requests.stop(self.shutdown_timeout) + + +class Gateway(object): + + """A base class to interface HTTPServer with other systems, such as WSGI. + """ + + def __init__(self, req): + self.req = req + + def respond(self): + """Process the current request. Must be overridden in a subclass.""" + raise NotImplemented + + +# These may either be wsgiserver.SSLAdapter subclasses or the string names +# of such classes (in which case they will be lazily loaded). +ssl_adapters = { + 'builtin': 'cherrypy.wsgiserver.ssl_builtin.BuiltinSSLAdapter', + 'pyopenssl': 'cherrypy.wsgiserver.ssl_pyopenssl.pyOpenSSLAdapter', +} + + +def get_ssl_adapter_class(name='builtin'): + """Return an SSL adapter class for the given name.""" + adapter = ssl_adapters[name.lower()] + if isinstance(adapter, basestring): + last_dot = adapter.rfind(".") + attr_name = adapter[last_dot + 1:] + mod_path = adapter[:last_dot] + + try: + mod = sys.modules[mod_path] + if mod is None: + raise KeyError() + except KeyError: + # The last [''] is important. + mod = __import__(mod_path, globals(), locals(), ['']) + + # Let an AttributeError propagate outward. + try: + adapter = getattr(mod, attr_name) + except AttributeError: + raise AttributeError("'%s' object has no attribute '%s'" + % (mod_path, attr_name)) + + return adapter + +# ------------------------------- WSGI Stuff -------------------------------- # + + +class CherryPyWSGIServer(HTTPServer): + + """A subclass of HTTPServer which calls a WSGI application.""" + + wsgi_version = (1, 0) + """The version of WSGI to produce.""" + + def __init__(self, bind_addr, wsgi_app, numthreads=10, server_name=None, + max=-1, request_queue_size=5, timeout=10, shutdown_timeout=5, + accepted_queue_size=-1, accepted_queue_timeout=10): + self.requests = ThreadPool(self, min=numthreads or 1, max=max, + accepted_queue_size=accepted_queue_size, + accepted_queue_timeout=accepted_queue_timeout) + self.wsgi_app = wsgi_app + self.gateway = wsgi_gateways[self.wsgi_version] + + self.bind_addr = bind_addr + if not server_name: + server_name = socket.gethostname() + self.server_name = server_name + self.request_queue_size = request_queue_size + + self.timeout = timeout + self.shutdown_timeout = shutdown_timeout + self.clear_stats() + + def _get_numthreads(self): + return self.requests.min + + def _set_numthreads(self, value): + self.requests.min = value + numthreads = property(_get_numthreads, _set_numthreads) + + +class WSGIGateway(Gateway): + + """A base class to interface HTTPServer with WSGI.""" + + def __init__(self, req): + self.req = req + self.started_response = False + self.env = self.get_environ() + self.remaining_bytes_out = None + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version""" + raise NotImplemented + + def respond(self): + """Process the current request.""" + response = self.req.server.wsgi_app(self.env, self.start_response) + try: + for chunk in response: + # "The start_response callable must not actually transmit + # the response headers. Instead, it must store them for the + # server or gateway to transmit only after the first + # iteration of the application return value that yields + # a NON-EMPTY string, or upon the application's first + # invocation of the write() callable." (PEP 333) + if chunk: + if isinstance(chunk, unicodestr): + chunk = chunk.encode('ISO-8859-1') + self.write(chunk) + finally: + if hasattr(response, "close"): + response.close() + + def start_response(self, status, headers, exc_info=None): + """WSGI callable to begin the HTTP response.""" + # "The application may call start_response more than once, + # if and only if the exc_info argument is provided." + if self.started_response and not exc_info: + raise AssertionError("WSGI start_response called a second " + "time with no exc_info.") + self.started_response = True + + # "if exc_info is provided, and the HTTP headers have already been + # sent, start_response must raise an error, and should raise the + # exc_info tuple." + if self.req.sent_headers: + try: + raise exc_info[0](exc_info[1]).with_traceback(exc_info[2]) + finally: + exc_info = None + + # According to PEP 3333, when using Python 3, the response status + # and headers must be bytes masquerading as unicode; that is, they + # must be of type "str" but are restricted to code points in the + # "latin-1" set. + if not isinstance(status, str): + raise TypeError("WSGI response status is not of type str.") + self.req.status = status.encode('ISO-8859-1') + + for k, v in headers: + if not isinstance(k, str): + raise TypeError( + "WSGI response header key %r is not of type str." % k) + if not isinstance(v, str): + raise TypeError( + "WSGI response header value %r is not of type str." % v) + if k.lower() == 'content-length': + self.remaining_bytes_out = int(v) + self.req.outheaders.append( + (k.encode('ISO-8859-1'), v.encode('ISO-8859-1'))) + + return self.write + + def write(self, chunk): + """WSGI callable to write unbuffered data to the client. + + This method is also used internally by start_response (to write + data from the iterable returned by the WSGI application). + """ + if not self.started_response: + raise AssertionError("WSGI write called before start_response.") + + chunklen = len(chunk) + rbo = self.remaining_bytes_out + if rbo is not None and chunklen > rbo: + if not self.req.sent_headers: + # Whew. We can send a 500 to the client. + self.req.simple_response("500 Internal Server Error", + "The requested resource returned " + "more bytes than the declared " + "Content-Length.") + else: + # Dang. We have probably already sent data. Truncate the chunk + # to fit (so the client doesn't hang) and raise an error later. + chunk = chunk[:rbo] + + if not self.req.sent_headers: + self.req.sent_headers = True + self.req.send_headers() + + self.req.write(chunk) + + if rbo is not None: + rbo -= chunklen + if rbo < 0: + raise ValueError( + "Response body exceeds the declared Content-Length.") + + +class WSGIGateway_10(WSGIGateway): + + """A Gateway class to interface HTTPServer with WSGI 1.0.x.""" + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version""" + req = self.req + env = { + # set a non-standard environ entry so the WSGI app can know what + # the *real* server protocol is (and what features to support). + # See http://www.faqs.org/rfcs/rfc2145.html. + 'ACTUAL_SERVER_PROTOCOL': req.server.protocol, + 'PATH_INFO': req.path.decode('ISO-8859-1'), + 'QUERY_STRING': req.qs.decode('ISO-8859-1'), + 'REMOTE_ADDR': req.conn.remote_addr or '', + 'REMOTE_PORT': str(req.conn.remote_port or ''), + 'REQUEST_METHOD': req.method.decode('ISO-8859-1'), + 'REQUEST_URI': req.uri.decode('ISO-8859-1'), + 'SCRIPT_NAME': '', + 'SERVER_NAME': req.server.server_name, + # Bah. "SERVER_PROTOCOL" is actually the REQUEST protocol. + 'SERVER_PROTOCOL': req.request_protocol.decode('ISO-8859-1'), + 'SERVER_SOFTWARE': req.server.software, + 'wsgi.errors': sys.stderr, + 'wsgi.input': req.rfile, + 'wsgi.multiprocess': False, + 'wsgi.multithread': True, + 'wsgi.run_once': False, + 'wsgi.url_scheme': req.scheme.decode('ISO-8859-1'), + 'wsgi.version': (1, 0), + } + if isinstance(req.server.bind_addr, basestring): + # AF_UNIX. This isn't really allowed by WSGI, which doesn't + # address unix domain sockets. But it's better than nothing. + env["SERVER_PORT"] = "" + else: + env["SERVER_PORT"] = str(req.server.bind_addr[1]) + + # Request headers + for k, v in req.inheaders.items(): + k = k.decode('ISO-8859-1').upper().replace("-", "_") + env["HTTP_" + k] = v.decode('ISO-8859-1') + + # CONTENT_TYPE/CONTENT_LENGTH + ct = env.pop("HTTP_CONTENT_TYPE", None) + if ct is not None: + env["CONTENT_TYPE"] = ct + cl = env.pop("HTTP_CONTENT_LENGTH", None) + if cl is not None: + env["CONTENT_LENGTH"] = cl + + if req.conn.ssl_env: + env.update(req.conn.ssl_env) + + return env + + +class WSGIGateway_u0(WSGIGateway_10): + + """A Gateway class to interface HTTPServer with WSGI u.0. + + WSGI u.0 is an experimental protocol, which uses unicode for keys + and values in both Python 2 and Python 3. + """ + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version""" + req = self.req + env_10 = WSGIGateway_10.get_environ(self) + env = env_10.copy() + env['wsgi.version'] = ('u', 0) + + # Request-URI + env.setdefault('wsgi.url_encoding', 'utf-8') + try: + # SCRIPT_NAME is the empty string, who cares what encoding it is? + env["PATH_INFO"] = req.path.decode(env['wsgi.url_encoding']) + env["QUERY_STRING"] = req.qs.decode(env['wsgi.url_encoding']) + except UnicodeDecodeError: + # Fall back to latin 1 so apps can transcode if needed. + env['wsgi.url_encoding'] = 'ISO-8859-1' + env["PATH_INFO"] = env_10["PATH_INFO"] + env["QUERY_STRING"] = env_10["QUERY_STRING"] + + return env + +wsgi_gateways = { + (1, 0): WSGIGateway_10, + ('u', 0): WSGIGateway_u0, +} + + +class WSGIPathInfoDispatcher(object): + + """A WSGI dispatcher for dispatch based on the PATH_INFO. + + apps: a dict or list of (path_prefix, app) pairs. + """ + + def __init__(self, apps): + try: + apps = list(apps.items()) + except AttributeError: + pass + + # Sort the apps by len(path), descending + apps.sort() + apps.reverse() + + # The path_prefix strings must start, but not end, with a slash. + # Use "" instead of "/". + self.apps = [(p.rstrip("/"), a) for p, a in apps] + + def __call__(self, environ, start_response): + path = environ["PATH_INFO"] or "/" + for p, app in self.apps: + # The apps list should be sorted by length, descending. + if path.startswith(p + "/") or path == p: + environ = environ.copy() + environ["SCRIPT_NAME"] = environ["SCRIPT_NAME"] + p + environ["PATH_INFO"] = path[len(p):] + return app(environ, start_response) + + start_response('404 Not Found', [('Content-Type', 'text/plain'), + ('Content-Length', '0')]) + return [''] From 3abea4ad3c014457a5ca79eace9b6e713c9d167c Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 19:03:01 -0700 Subject: [PATCH 011/132] Enable guest login with Plex.tv account --- data/interfaces/default/base.html | 19 ++++-- plexpy/__init__.py | 18 ++++- plexpy/http_handler.py | 2 +- plexpy/plextv.py | 46 ++++++++++--- plexpy/users.py | 108 ++++++++++++++++++++++++++---- plexpy/webauth.py | 34 ++++++---- plexpy/webserve.py | 25 ++++--- 7 files changed, 195 insertions(+), 57 deletions(-) diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index bd3a5611..dd48b955 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -212,17 +212,22 @@ from plexpy.helpers import anon_url % endif diff --git a/plexpy/__init__.py b/plexpy/__init__.py index 51e9cbb9..d6c446bd 100644 --- a/plexpy/__init__.py +++ b/plexpy/__init__.py @@ -447,7 +447,8 @@ def dbcheck(): 'user_id INTEGER DEFAULT NULL UNIQUE, username TEXT NOT NULL, friendly_name TEXT, ' 'thumb TEXT, custom_avatar_url TEXT, email TEXT, is_home_user INTEGER DEFAULT NULL, ' 'is_allow_sync INTEGER DEFAULT NULL, is_restricted INTEGER DEFAULT NULL, do_notify INTEGER DEFAULT 1, ' - 'keep_history INTEGER DEFAULT 1, deleted_user INTEGER DEFAULT 0)' + 'keep_history INTEGER DEFAULT 1, deleted_user INTEGER DEFAULT 0, allow_guest INTEGER DEFAULT 1, ' + 'user_token TEXT, server_token TEXT)' ) # notify_log table :: This is a table which logs notifications sent @@ -742,6 +743,21 @@ def dbcheck(): 'ALTER TABLE users ADD COLUMN deleted_user INTEGER DEFAULT 0' ) + # Upgrade users table from earlier versions + try: + c_db.execute('SELECT allow_guest FROM users') + except sqlite3.OperationalError: + logger.debug(u"Altering database. Updating database table users.") + c_db.execute( + 'ALTER TABLE users ADD COLUMN allow_guest INTEGER DEFAULT 1' + ) + c_db.execute( + 'ALTER TABLE users ADD COLUMN user_token TEXT' + ) + c_db.execute( + 'ALTER TABLE users ADD COLUMN server_token TEXT' + ) + # Upgrade notify_log table from earlier versions try: c_db.execute('SELECT poster_url FROM notify_log') diff --git a/plexpy/http_handler.py b/plexpy/http_handler.py index 199ad647..2c6561e7 100644 --- a/plexpy/http_handler.py +++ b/plexpy/http_handler.py @@ -90,7 +90,7 @@ class HTTPHandler(object): logger.warn(u"Failed to access uri endpoint %s with Uncaught exception." % uri) return None - if request_status == 200: + if request_status in (200, 201): try: if output_format == 'dict': output = helpers.convert_xml_to_dict(request_content) diff --git a/plexpy/plextv.py b/plexpy/plextv.py index 9963509d..dbde8697 100644 --- a/plexpy/plextv.py +++ b/plexpy/plextv.py @@ -20,6 +20,7 @@ from plexpy import logger, helpers, http_handler, database, users import xmltodict import json from xml.dom import minidom +import requests import base64 import plexpy @@ -109,34 +110,38 @@ class PlexTV(object): Plex.tv authentication """ - def __init__(self, username=None, password=None): + def __init__(self, username=None, password=None, token=None): self.protocol = 'HTTPS' self.username = username self.password = password self.ssl_verify = plexpy.CONFIG.VERIFY_SSL_CERT + token = token if token else plexpy.CONFIG.PMS_TOKEN + self.request_handler = http_handler.HTTPHandler(host='plex.tv', port=443, - token=plexpy.CONFIG.PMS_TOKEN, + token=token, ssl_verify=self.ssl_verify) def get_plex_auth(self, output_format='raw'): uri = '/users/sign_in.xml' base64string = base64.encodestring('%s:%s' % (self.username, self.password)).replace('\n', '') headers = {'Content-Type': 'application/xml; charset=utf-8', - 'Content-Length': '0', 'X-Plex-Device-Name': 'PlexPy', 'X-Plex-Product': 'PlexPy', - 'X-Plex-Version': 'v0.1 dev', + 'X-Plex-Version': plexpy.common.VERSION_NUMBER, + 'X-Plex-Platform': plexpy.common.PLATFORM, + 'X-Plex-Platform-Version': plexpy.common.PLATFORM_VERSION, 'X-Plex-Client-Identifier': plexpy.CONFIG.PMS_UUID, - 'Authorization': 'Basic %s' % base64string + ":" + 'Authorization': 'Basic %s' % base64string } - + request = self.request_handler.make_request(uri=uri, proto=self.protocol, request_type='POST', headers=headers, - output_format=output_format) + output_format=output_format, + no_token=True) return request @@ -147,16 +152,35 @@ class PlexTV(object): try: xml_head = plextv_response.getElementsByTagName('user') if xml_head: - auth_token = xml_head[0].getAttribute('authenticationToken') + user = {'auth_token': xml_head[0].getAttribute('authenticationToken'), + 'user_id': xml_head[0].getAttribute('id') + } else: logger.warn(u"PlexPy PlexTV :: Could not get Plex authentication token.") except Exception as e: logger.warn(u"PlexPy PlexTV :: Unable to parse XML for get_token: %s." % e) - return [] + return None - return auth_token + return user else: - return [] + return None + + def get_server_token(self): + servers = self.get_plextv_server_list(output_format='xml') + server_token = '' + + try: + xml_head = servers.getElementsByTagName('Server') + except Exception as e: + logger.warn(u"PlexPy PlexTV :: Unable to parse XML for get_server_token: %s." % e) + return None + + for a in xml_head: + if helpers.get_xml_attr(a, 'machineIdentifier') == plexpy.CONFIG.PMS_IDENTIFIER: + server_token = helpers.get_xml_attr(a, 'accessToken') + break + + return server_token def get_plextv_user_data(self): plextv_response = self.get_plex_auth(output_format='dict') diff --git a/plexpy/users.py b/plexpy/users.py index 012188d0..1ce4d443 100644 --- a/plexpy/users.py +++ b/plexpy/users.py @@ -15,6 +15,63 @@ from plexpy import logger, datatables, common, database, helpers +def user_login(username=None, password=None): + from plexpy import plextv + + if not username and not password: + return None + + user_data = Users() + + # Try to login to Plex.tv to check if the user has a vaild account + plex_tv = plextv.PlexTV(username=username, password=password) + user = plex_tv.get_token() + if user: + user_token = user['auth_token'] + user_id = user['user_id'] + + # Retrieve user token from the database and check against the Plex.tv token. + # Also Make sure 'allow_guest' access is enabled for the user. + # The user tokens should match if it is the same PlexPy install. + tokens = user_data.get_tokens(user_id=user_id) + if tokens and tokens['allow_guest'] and user_token == tokens['user_token']: + # Successful login + return True + + # Otherwise it is a new user or token is no longer valid. + # Check if the user is in the database. + user_details = user_data.get_details(user_id=user_id) + if user_details['allow_guest'] and user_id == str(user_details['user_id']): + + # The user is in the database, so try to retrieve a new server token. + # If a server token is returned, then the user is a vaild friend + plex_tv = plextv.PlexTV(token=user_token) + server_token = plex_tv.get_server_token() + if server_token: + + # Register the new user / update the access tokens. + monitor_db = database.MonitorDatabase() + try: + logger.debug(u"PlexPy Users :: Regestering tokens for user '%s' in the database." % username) + monitor_db.action('UPDATE users SET user_token = ?, server_token = ? WHERE user_id = ?', + [user_token, server_token, user_id]) + # Successful login + return True + except Exception as e: + logger.warn(u"PlexPy Users :: Unable to register user '%s' in database: %s." % (username, e)) + return None + else: + logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv server token.") + return None + else: + logger.warn(u"PlexPy Users :: Unable to register user '%s'. User not in the database." % username) + return None + else: + logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv user token.") + return None + + return None + class Users(object): @@ -69,7 +126,8 @@ class Users(object): 'session_history_metadata.parent_media_index', 'session_history_media_info.transcode_decision', 'users.do_notify as do_notify', - 'users.keep_history as keep_history' + 'users.keep_history as keep_history', + 'users.allow_guest as allow_guest' ] try: query = data_tables.ssp_query(table_name='users', @@ -135,7 +193,8 @@ class Users(object): 'parent_media_index': item['parent_media_index'], 'transcode_decision': item['transcode_decision'], 'do_notify': helpers.checked(item['do_notify']), - 'keep_history': helpers.checked(item['keep_history']) + 'keep_history': helpers.checked(item['keep_history']), + 'allow_guest': helpers.checked(item['allow_guest']) } rows.append(row) @@ -241,7 +300,7 @@ class Users(object): return dict - def set_config(self, user_id=None, friendly_name='', custom_thumb='', do_notify=1, keep_history=1): + def set_config(self, user_id=None, friendly_name='', custom_thumb='', do_notify=1, keep_history=1, allow_guest=1): if str(user_id).isdigit(): monitor_db = database.MonitorDatabase() @@ -249,7 +308,9 @@ class Users(object): value_dict = {'friendly_name': friendly_name, 'custom_avatar_url': custom_thumb, 'do_notify': do_notify, - 'keep_history': keep_history} + 'keep_history': keep_history, + 'allow_guest': allow_guest + } try: monitor_db.upsert('users', value_dict, key_dict) except Exception as e: @@ -267,7 +328,8 @@ class Users(object): 'is_allow_sync': 0, 'is_restricted': 0, 'do_notify': 0, - 'keep_history': 1 + 'keep_history': 1, + 'allow_guest': 0 } if not user_id and not user: @@ -279,13 +341,13 @@ class Users(object): try: if str(user_id).isdigit(): query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history ' \ + 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, allow_guest ' \ 'FROM users ' \ 'WHERE user_id = ? ' result = monitor_db.select(query, args=[user_id]) elif user: query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history ' \ + 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, allow_guest ' \ 'FROM users ' \ 'WHERE username = ? ' result = monitor_db.select(query, args=[user]) @@ -319,7 +381,8 @@ class Users(object): 'is_allow_sync': item['is_allow_sync'], 'is_restricted': item['is_restricted'], 'do_notify': item['do_notify'], - 'keep_history': item['keep_history'] + 'keep_history': item['keep_history'], + 'allow_guest': item['allow_guest'] } return user_details @@ -488,7 +551,7 @@ class Users(object): try: if str(user_id).isdigit(): - logger.info(u"PlexPy DataFactory :: Deleting all history for user id %s from database." % user_id) + logger.info(u"PlexPy Users :: Deleting all history for user id %s from database." % user_id) session_history_media_info_del = \ monitor_db.action('DELETE FROM ' 'session_history_media_info ' @@ -520,7 +583,7 @@ class Users(object): try: if str(user_id).isdigit(): self.delete_all_history(user_id) - logger.info(u"PlexPy DataFactory :: Deleting user with id %s from database." % user_id) + logger.info(u"PlexPy Users :: Deleting user with id %s from database." % user_id) monitor_db.action('UPDATE users SET deleted_user = 1 WHERE user_id = ?', [user_id]) monitor_db.action('UPDATE users SET keep_history = 0 WHERE user_id = ?', [user_id]) monitor_db.action('UPDATE users SET do_notify = 0 WHERE user_id = ?', [user_id]) @@ -536,14 +599,14 @@ class Users(object): try: if user_id and str(user_id).isdigit(): - logger.info(u"PlexPy DataFactory :: Re-adding user with id %s to database." % user_id) + logger.info(u"PlexPy Users :: Re-adding user with id %s to database." % user_id) monitor_db.action('UPDATE users SET deleted_user = 0 WHERE user_id = ?', [user_id]) monitor_db.action('UPDATE users SET keep_history = 1 WHERE user_id = ?', [user_id]) monitor_db.action('UPDATE users SET do_notify = 1 WHERE user_id = ?', [user_id]) return 'Re-added user with id %s.' % user_id elif username: - logger.info(u"PlexPy DataFactory :: Re-adding user with username %s to database." % username) + logger.info(u"PlexPy Users :: Re-adding user with username %s to database." % username) monitor_db.action('UPDATE users SET deleted_user = 0 WHERE username = ?', [username]) monitor_db.action('UPDATE users SET keep_history = 1 WHERE username = ?', [username]) monitor_db.action('UPDATE users SET do_notify = 1 WHERE username = ?', [username]) @@ -568,4 +631,23 @@ class Users(object): except: return None - return None \ No newline at end of file + return None + + def get_tokens(self, user_id=None): + if user_id: + try: + monitor_db = database.MonitorDatabase() + query = 'SELECT allow_guest, user_token, server_token FROM users WHERE user_id = ?' + result = monitor_db.select_single(query, args=[user_id]) + if result: + tokens = {'allow_guest': result['allow_guest'], + 'user_token': result['user_token'], + 'server_token': result['server_token'] + } + return tokens + else: + return None + except: + return None + + return None diff --git a/plexpy/webauth.py b/plexpy/webauth.py index 87d1068e..94427c35 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -25,6 +25,7 @@ from datetime import datetime, timedelta import plexpy from plexpy import logger +from plexpy.users import user_login SESSION_KEY = '_cp_username' @@ -32,13 +33,16 @@ SESSION_KEY = '_cp_username' def check_credentials(username, password): """Verifies credentials for username and password. Returns None on success or a string describing the error on failure""" + if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \ username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD): - return None + return True, u'admin' elif username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: - return None + return True, u'admin' + elif user_login(username, password): + return True, u'guest' else: - return u"Incorrect username or password." + return False, None # An example implementation which uses an ORM could be: # u = User.get(username) @@ -53,7 +57,9 @@ def check_auth(*args, **kwargs): conditions that the user must fulfill""" conditions = cherrypy.request.config.get('auth.require', None) if conditions is not None: - (username, expiry) = cherrypy.session.get(SESSION_KEY) if cherrypy.session.get(SESSION_KEY) else (None, None) + session = cherrypy.session.get(SESSION_KEY) + username, user_group, expiry = session if session else (None, None, None) + if (username and expiry) and expiry > datetime.now(): cherrypy.request.login = username for condition in conditions: @@ -143,28 +149,30 @@ class AuthController(object): if username is None or password is None: return self.get_loginform() - error_msg = check_credentials(username, password) + (vaild_login, user_group) = check_credentials(username, password) - if error_msg: - logger.debug(u"Invalid login attempt from '%s'." % username) - return self.get_loginform(username, error_msg) - else: + if vaild_login: cherrypy.session.regenerate() cherrypy.request.login = username expiry = datetime.now() + (timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)) - cherrypy.session[SESSION_KEY] = (username, expiry) + cherrypy.session[SESSION_KEY] = (username, user_group, expiry) self.on_login(username) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + + else: + logger.debug(u"Invalid login attempt from '%s'." % username) + return self.get_loginform(username, u"Incorrect username or password.") @cherrypy.expose def logout(self): if not plexpy.CONFIG.HTTP_PASSWORD: raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) - sess = cherrypy.session - (username, expiry) = sess.get(SESSION_KEY) if sess.get(SESSION_KEY) else (None, None) - sess[SESSION_KEY] = None + cp_sess = cherrypy.session + session = cp_sess.get(SESSION_KEY) + username, user_group, expiry = session if session else (None, None, None) + cp_sess[SESSION_KEY] = None if username: cherrypy.request.login = None diff --git a/plexpy/webserve.py b/plexpy/webserve.py index c27ab9cc..09973df2 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -16,7 +16,7 @@ from plexpy import logger, notifiers, plextv, pmsconnect, common, log_reader, \ datafactory, graphs, users, libraries, database, web_socket from plexpy.helpers import checked, addtoapi, get_ip, create_https_certificates -from plexpy.webauth import AuthController, requireAuth, member_of, name_is +from plexpy.webauth import AuthController, requireAuth, member_of, name_is, SESSION_KEY from mako.lookup import TemplateLookup from mako import exceptions @@ -49,9 +49,13 @@ def serve_template(templatename, **kwargs): server_name = plexpy.CONFIG.PMS_NAME + session = cherrypy.session.get(SESSION_KEY) + user, user_group, expiry = session if session else (None, None, None) + try: template = _hplookup.get_template(templatename) - return template.render(server_name=server_name, http_root=plexpy.HTTP_ROOT, **kwargs) + return template.render(http_root=plexpy.HTTP_ROOT, server_name=server_name, + user=user, user_group=user_group, expiry=expiry, **kwargs) except: return exceptions.html_error_template().render() @@ -64,7 +68,7 @@ class WebInterface(object): self.interface_dir = os.path.join(str(plexpy.PROG_DIR), 'data/') @cherrypy.expose - @requireAuth(member_of("admin")) + @requireAuth() def index(self): if plexpy.CONFIG.FIRST_RUN_COMPLETE: raise cherrypy.HTTPRedirect("home") @@ -149,7 +153,7 @@ class WebInterface(object): ##### Home ##### @cherrypy.expose - @requireAuth(member_of("admin")) + @requireAuth() def home(self): config = { "home_sections": plexpy.CONFIG.HOME_SECTIONS, @@ -162,7 +166,6 @@ class WebInterface(object): return serve_template(templatename="index.html", title="Home", config=config) @cherrypy.expose - @requireAuth(member_of("admin")) @addtoapi() def get_date_formats(self): """ Get the date and time formats used by plexpy """ @@ -183,7 +186,6 @@ class WebInterface(object): return json.dumps(formats) @cherrypy.expose - @requireAuth(member_of("admin")) def get_current_activity(self, **kwargs): try: @@ -206,7 +208,6 @@ class WebInterface(object): return serve_template(templatename="current_activity.html", data=None) @cherrypy.expose - @requireAuth(member_of("admin")) def get_current_activity_header(self, **kwargs): try: @@ -222,7 +223,6 @@ class WebInterface(object): return serve_template(templatename="current_activity_header.html", data=None) @cherrypy.expose - @requireAuth(member_of("admin")) def home_stats(self, **kwargs): data_factory = datafactory.DataFactory() @@ -243,7 +243,6 @@ class WebInterface(object): return serve_template(templatename="home_stats.html", title="Stats", data=stats_data) @cherrypy.expose - @requireAuth(member_of("admin")) def library_stats(self, **kwargs): data_factory = datafactory.DataFactory() @@ -254,7 +253,6 @@ class WebInterface(object): return serve_template(templatename="library_stats.html", title="Library Stats", data=stats_data) @cherrypy.expose - @requireAuth(member_of("admin")) def get_recently_added(self, count='0', **kwargs): try: @@ -1847,7 +1845,7 @@ class WebInterface(object): return serve_template(templatename="info_children_list.html", data=None, title="Children List") @cherrypy.expose - @requireAuth(member_of("admin")) + @requireAuth() def pms_image_proxy(self, img='', width='0', height='0', fallback=None, **kwargs): try: pms_connect = pmsconnect.PmsConnect() @@ -2433,3 +2431,8 @@ class WebInterface(object): pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_update_staus() return json.dumps(result) + + @cherrypy.expose + def test_guest_login(self, username=None, password=None): + result = users.user_login(username=username, password=password) + return result From 4be41336b3c35cb02333175468f68f8179c5345d Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 19:09:28 -0700 Subject: [PATCH 012/132] Missing require auth for getLog --- plexpy/webserve.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 09973df2..0c293ce3 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -1121,6 +1121,7 @@ class WebInterface(object): return serve_template(templatename="logs.html", title="Log", lineList=plexpy.LOG_LIST) @cherrypy.expose + @requireAuth(member_of("admin")) def getLog(self, start=0, length=100, **kwargs): start = int(start) length = int(length) From 62600a450aa9c1148623766755d681ecf5e60b0e Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 21:43:10 -0700 Subject: [PATCH 013/132] Add global allow guest access setting and per user toggles --- data/interfaces/default/edit_user.html | 14 +++++++++- data/interfaces/default/js/tables/users.js | 6 +++++ data/interfaces/default/settings.html | 10 ++++++++ plexpy/config.py | 1 + plexpy/users.py | 30 ++++++++++++++-------- plexpy/webauth.py | 2 +- plexpy/webserve.py | 8 ++++-- 7 files changed, 56 insertions(+), 15 deletions(-) diff --git a/data/interfaces/default/edit_user.html b/data/interfaces/default/edit_user.html index 8d5626ca..1ba3f645 100644 --- a/data/interfaces/default/edit_user.html +++ b/data/interfaces/default/edit_user.html @@ -20,6 +20,7 @@ is_allow_sync Returns bool value for whether the user has sync rights. is_restricted Returns bool value for whether the user account is restricted. do_notify Returns bool value for whether to send notifications for the user. keep_history Returns bool value for whether to keep history for the user. +allow_guest Returns bool value for whether to allow guest access for the user. DOCUMENTATION :: END @@ -67,6 +68,12 @@ DOCUMENTATION :: END

    Uncheck this if you do not want to keep any history on this user's activity.

    +
    + +

    Uncheck this if you do not want to allow this user to login to PlexPy.

    +
    % if data['user_id']:
    @@ -108,12 +115,16 @@ DOCUMENTATION :: END var custom_thumb = $("#custom_avatar_url").val(); var do_notify = 0; var keep_history = 0; + var allow_guest = 0; if ($("#do_notify").is(":checked")) { do_notify = 1; } if ($("#keep_history").is(":checked")) { keep_history = 1; } + if ($("#allow_guest").is(":checked")) { + allow_guest = 1; + } $.ajax({ url: 'edit_user', @@ -122,7 +133,8 @@ DOCUMENTATION :: END friendly_name: friendly_name, custom_thumb: custom_thumb, do_notify: do_notify, - keep_history: keep_history + keep_history: keep_history, + allow_guest: allow_guest }, cache: false, async: true, diff --git a/data/interfaces/default/js/tables/users.js b/data/interfaces/default/js/tables/users.js index acbf4d3b..2a5b7717 100644 --- a/data/interfaces/default/js/tables/users.js +++ b/data/interfaces/default/js/tables/users.js @@ -45,6 +45,7 @@ users_list_table_options = { '   ' + ' ' + ' ' + + ' ' + '
    '); }, "width": "7%", @@ -300,12 +301,16 @@ $('#users_list_table').on('change', 'td.edit-control > .edit-user-toggles > inpu var do_notify = 0; var keep_history = 0; + var allow_guest = 0; if ($('#do_notify-' + rowData['user_id']).is(':checked')) { do_notify = 1; } if ($('#keep_history-' + rowData['user_id']).is(':checked')) { keep_history = 1; } + if ($('#allow_guest-' + rowData['user_id']).is(':checked')) { + allow_guest = 1; + } friendly_name = tr.find('td.edit-user-control > .edit-user-name > input').val(); @@ -316,6 +321,7 @@ $('#users_list_table').on('change', 'td.edit-control > .edit-user-toggles > inpu friendly_name: friendly_name, do_notify: do_notify, keep_history: keep_history, + allow_guest: allow_guest, thumb: rowData['user_thumb'] }, cache: false, diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 880229de..892c120b 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -520,6 +520,16 @@ available_notification_agents = sorted(notifiers.available_notification_agents() data-parsley-errors-container="#http_hash_password_error" data-parsley-error-message="Cannot un-hash password, please set a new password." data-parsley-no-focus required> +
    +

    Guest Access

    +
    +
    + +

    Allow shared users to login to PlexPy using their Plex.tv account.

    +
    +

    API

    diff --git a/plexpy/config.py b/plexpy/config.py index 14b413a6..49ca6f67 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -13,6 +13,7 @@ def bool_int(value): return int(bool(value)) _CONFIG_DEFINITIONS = { + 'ALLOW_GUEST_ACCESS': (int, 'General', 0), 'DATE_FORMAT': (str, 'General', 'YYYY-MM-DD'), 'GROUPING_GLOBAL_HISTORY': (int, 'PlexWatch', 0), 'GROUPING_USER_HISTORY': (int, 'PlexWatch', 0), diff --git a/plexpy/users.py b/plexpy/users.py index 1ce4d443..e3f28092 100644 --- a/plexpy/users.py +++ b/plexpy/users.py @@ -39,9 +39,10 @@ def user_login(username=None, password=None): return True # Otherwise it is a new user or token is no longer valid. - # Check if the user is in the database. + # Check if the user is in the database, not deleted, and 'allow_guest' access. user_details = user_data.get_details(user_id=user_id) - if user_details['allow_guest'] and user_id == str(user_details['user_id']): + if user_id == str(user_details['user_id']) and \ + not user_details['deleted_user'] and user_details['allow_guest']: # The user is in the database, so try to retrieve a new server token. # If a server token is returned, then the user is a vaild friend @@ -53,21 +54,26 @@ def user_login(username=None, password=None): monitor_db = database.MonitorDatabase() try: logger.debug(u"PlexPy Users :: Regestering tokens for user '%s' in the database." % username) - monitor_db.action('UPDATE users SET user_token = ?, server_token = ? WHERE user_id = ?', - [user_token, server_token, user_id]) - # Successful login - return True + result = monitor_db.action('UPDATE users SET user_token = ?, server_token = ? WHERE user_id = ?', + [user_token, server_token, user_id]) + + if result: + # Successful login + return True + else: + logger.warn(u"PlexPy Users :: Unable to register user '%s' in database." % username) + return None except Exception as e: logger.warn(u"PlexPy Users :: Unable to register user '%s' in database: %s." % (username, e)) return None else: - logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv server token.") + logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv server token for user '%s'." % username) return None else: logger.warn(u"PlexPy Users :: Unable to register user '%s'. User not in the database." % username) return None else: - logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv user token.") + logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv user token for user '%s'." % username) return None return None @@ -341,13 +347,13 @@ class Users(object): try: if str(user_id).isdigit(): query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, allow_guest ' \ + 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, allow_guest ' \ 'FROM users ' \ 'WHERE user_id = ? ' result = monitor_db.select(query, args=[user_id]) elif user: query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, allow_guest ' \ + 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, allow_guest ' \ 'FROM users ' \ 'WHERE username = ? ' result = monitor_db.select(query, args=[user]) @@ -382,6 +388,7 @@ class Users(object): 'is_restricted': item['is_restricted'], 'do_notify': item['do_notify'], 'keep_history': item['keep_history'], + 'deleted_user': item['deleted_user'], 'allow_guest': item['allow_guest'] } return user_details @@ -637,7 +644,8 @@ class Users(object): if user_id: try: monitor_db = database.MonitorDatabase() - query = 'SELECT allow_guest, user_token, server_token FROM users WHERE user_id = ?' + query = 'SELECT allow_guest, user_token, server_token FROM users ' \ + 'WHERE user_id = ? AND deleted_user = 0' result = monitor_db.select_single(query, args=[user_id]) if result: tokens = {'allow_guest': result['allow_guest'], diff --git a/plexpy/webauth.py b/plexpy/webauth.py index 94427c35..aaf15eda 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -39,7 +39,7 @@ def check_credentials(username, password): return True, u'admin' elif username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: return True, u'admin' - elif user_login(username, password): + elif plexpy.CONFIG.ALLOW_GUEST_ACCESS and user_login(username, password): return True, u'guest' else: return False, None diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 0c293ce3..1d732aa8 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -671,6 +671,7 @@ class WebInterface(object): custom_thumb = kwargs.get('custom_thumb', '') do_notify = kwargs.get('do_notify', 0) keep_history = kwargs.get('keep_history', 0) + allow_guest = kwargs.get('allow_guest', 0) user_data = users.Users() if user_id: @@ -679,7 +680,8 @@ class WebInterface(object): friendly_name=friendly_name, custom_thumb=custom_thumb, do_notify=do_notify, - keep_history=keep_history) + keep_history=keep_history, + allow_guest=allow_guest) status_message = "Successfully updated user." return status_message except: @@ -1257,6 +1259,7 @@ class WebInterface(object): http_password = '' config = { + "allow_guest_access": checked(plexpy.CONFIG.ALLOW_GUEST_ACCESS), "http_hash_password": checked(plexpy.CONFIG.HTTP_HASH_PASSWORD), "http_hashed_password": plexpy.CONFIG.HTTP_HASHED_PASSWORD, "http_host": plexpy.CONFIG.HTTP_HOST, @@ -1384,7 +1387,8 @@ class WebInterface(object): "refresh_libraries_on_startup", "refresh_users_on_startup", "ip_logging_enable", "movie_logging_enable", "tv_logging_enable", "music_logging_enable", "notify_consecutive", "notify_upload_posters", "notify_recently_added", "notify_recently_added_grandparent", - "monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist", "http_hash_password" + "monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist", "http_hash_password", + "allow_guest_access" ] for checked_config in checked_configs: if checked_config not in kwargs: From c0b01814753aefa438d833f52cf057d6800d74a7 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sat, 23 Apr 2016 22:22:27 -0700 Subject: [PATCH 014/132] Some cleanup --- data/interfaces/default/base.html | 3 +++ data/interfaces/default/settings.html | 7 +----- plexpy/users.py | 34 +++++++++++++-------------- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index dd48b955..1d4d4c25 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -217,6 +217,9 @@ from plexpy.helpers import anon_url
  • View Logs
  • +
  • Paypal
  • +
  • Bitcoin
  • + % if plexpy.CONFIG.CHECK_GITHUB:
  • Check for Updates
  • % endif diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 892c120b..52931d0f 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -38,7 +38,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
    % if top_stat['rows'][loop.index]['user_id']: - % elif top_stat['rows'][loop.index]['user']: - % endif % if top_stat['rows'][loop.index]['user_thumb'] != '':
    @@ -618,7 +610,7 @@ DOCUMENTATION :: END
    % endif - % if top_stat['rows'][loop.index]['user_id'] or top_stat['rows'][loop.index]['user']: + % if top_stat['rows'][loop.index]['user_id']:
    % endif
    @@ -711,11 +703,11 @@ DOCUMENTATION :: END
    % if top_stat['rows'][0]['user_id']: - % else: - - % endif ${top_stat['rows'][0]['friendly_name']} + % else: + ${top_stat['rows'][0]['friendly_name']} + % endif

    @@ -757,11 +749,11 @@ DOCUMENTATION :: END

    % if top_stat['rows'][loop.index]['user_id']: - % else: - - % endif ${top_stat['rows'][loop.index]['friendly_name']} + % else: + ${top_stat['rows'][loop.index]['friendly_name']} + % endif

    diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html index 979ea318..791d5903 100644 --- a/data/interfaces/default/library.html +++ b/data/interfaces/default/library.html @@ -181,10 +181,12 @@ DOCUMENTATION :: END

    + % if _session['user_group'] == 'admin': + % endif
    diff --git a/data/interfaces/default/library_user_stats.html b/data/interfaces/default/library_user_stats.html index c8bf832a..6120b5c3 100644 --- a/data/interfaces/default/library_user_stats.html +++ b/data/interfaces/default/library_user_stats.html @@ -10,9 +10,9 @@ Variable names: data [array] data[array_index] :: Usable parameters == Global keys == -user Returns the name of the user. +friendly_name Returns the friendly name of the user. user_id Returns the user id of the user. -thumb Returns the avatar of the user. +user_thumb Returns the avatar of the user. total_plays Returns the play count for the user. DOCUMENTATION :: END @@ -23,12 +23,19 @@ DOCUMENTATION :: END
    • - -
      + % if a['user_id']: +
      +
      + % else: +
      +
      + ${a['friendly_name']} +
      + % endif

      ${a['total_plays']}

      plays

      diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py index 7401b3e8..f94fa111 100644 --- a/plexpy/datafactory.py +++ b/plexpy/datafactory.py @@ -13,10 +13,10 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, datatables, common, database, helpers - import datetime +from plexpy import logger, datatables, common, database, helpers, session + class DataFactory(object): """ @@ -29,6 +29,23 @@ class DataFactory(object): def get_datatables_history(self, kwargs=None, custom_where=None, grouping=0, watched_percent=85): data_tables = datatables.DataTables() + if session.get_session_user_id(): + session_user_id = str(session.get_session_user_id()) + added = False + + for c_where in custom_where: + if 'user_id' in c_where[0]: + # This currently only works if c_where[1] is not a list or tuple + if str(c_where[1]) == session_user_id: + added = True + break + else: + c_where[1] = (c_where[1], session_user_id) + added = True + + if not added: + custom_where = [['session_history.user_id', session.get_session_user_id()]] + group_by = ['session_history.reference_id'] if grouping else ['session_history.id'] columns = ['session_history.reference_id', @@ -148,7 +165,7 @@ class DataFactory(object): dict = {'recordsFiltered': query['filteredCount'], 'recordsTotal': query['totalCount'], - 'data': helpers.filter_session_info(rows, 'user_id'), + 'data': rows, 'draw': query['draw'], 'filter_duration': helpers.human_duration(filter_duration, sig='dhm'), 'total_duration': helpers.human_duration(total_duration, sig='dhm') @@ -162,6 +179,13 @@ class DataFactory(object): group_by = 'session_history.reference_id' if grouping else 'session_history.id' sort_type = 'total_plays' if stats_type == 0 else 'total_duration' + library_cond = '' + if session.get_session_libraries(): + library_cond = 'AND (' + for section_id in session.get_session_libraries(): + library_cond += 'session_history_metadata.section_id = %s OR ' % section_id + library_cond = library_cond.rstrip(' OR ') + ')' + home_stats = [] for stat in stats_cards: @@ -177,11 +201,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "episode" ' \ + ' AND session_history.media_type = "episode" %s ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.grandparent_title ' \ 'ORDER BY %s DESC ' \ - 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: top_tv: %s." % e) @@ -207,7 +231,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': helpers.filter_session_info(top_tv, 'section_id')}) + 'rows': session.mask_session_info(top_tv, mask_metadata=True)}) elif stat == 'popular_tv': popular_tv = [] @@ -222,11 +246,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "episode" ' \ + ' AND session_history.media_type = "episode" %s ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.grandparent_title ' \ 'ORDER BY users_watched DESC, %s DESC ' \ - 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: popular_tv: %s." % e) @@ -250,7 +274,7 @@ class DataFactory(object): popular_tv.append(row) home_stats.append({'stat_id': stat, - 'rows': helpers.filter_session_info(popular_tv, 'section_id')}) + 'rows': session.mask_session_info(popular_tv, mask_metadata=True)}) elif stat == 'top_movies': top_movies = [] @@ -264,11 +288,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "movie" ' \ + ' AND session_history.media_type = "movie" %s ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.full_title ' \ 'ORDER BY %s DESC ' \ - 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: top_movies: %s." % e) @@ -291,9 +315,10 @@ class DataFactory(object): 'row_id': item['id'] } top_movies.append(row) + home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': helpers.filter_session_info(top_movies, 'section_id')}) + 'rows': session.mask_session_info(top_movies, mask_metadata=True)}) elif stat == 'popular_movies': popular_movies = [] @@ -308,11 +333,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "movie" ' \ + ' AND session_history.media_type = "movie" %s ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.full_title ' \ 'ORDER BY users_watched DESC, %s DESC ' \ - 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: popular_movies: %s." % e) @@ -336,7 +361,7 @@ class DataFactory(object): popular_movies.append(row) home_stats.append({'stat_id': stat, - 'rows': helpers.filter_session_info(popular_movies, 'section_id')}) + 'rows': session.mask_session_info(popular_movies, mask_metadata=True)}) elif stat == 'top_music': top_music = [] @@ -350,11 +375,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "track" ' \ + ' AND session_history.media_type = "track" %s ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.grandparent_title ' \ 'ORDER BY %s DESC ' \ - 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: top_music: %s." % e) @@ -380,7 +405,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': helpers.filter_session_info(top_music, 'section_id')}) + 'rows': session.mask_session_info(top_music, mask_metadata=True)}) elif stat == 'popular_music': popular_music = [] @@ -395,11 +420,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "track" ' \ + ' AND session_history.media_type = "track" %s ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.grandparent_title ' \ 'ORDER BY users_watched DESC, %s DESC ' \ - 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: popular_music: %s." % e) @@ -423,7 +448,7 @@ class DataFactory(object): popular_music.append(row) home_stats.append({'stat_id': stat, - 'rows': helpers.filter_session_info(popular_music, 'section_id')}) + 'rows': session.mask_session_info(popular_music, mask_metadata=True)}) elif stat == 'top_users': top_users = [] @@ -476,7 +501,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': helpers.mask_session_info(top_users)}) + 'rows': session.mask_session_info(top_users, mask_metadata=True)}) elif stat == 'top_platforms': top_platform = [] @@ -522,13 +547,13 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': top_platform}) + 'rows': session.mask_session_info(top_platform, mask_metadata=True)}) elif stat == 'last_watched': last_watched = [] try: query = 'SELECT t.id, t.full_title, t.rating_key, t.thumb, t.grandparent_thumb, ' \ - 't.user, t.user_id, t.custom_avatar_url as user_thumb, t.player, ' \ + 't.user, t.user_id, t.custom_avatar_url as user_thumb, t.player, t.section_id, ' \ '(CASE WHEN t.friendly_name IS NULL THEN t.username ELSE t.friendly_name END) ' \ ' AS friendly_name, ' \ 'MAX(t.started) AS last_watch, ' \ @@ -541,12 +566,12 @@ class DataFactory(object): ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ ' AND (session_history.media_type = "movie" ' \ - ' OR session_history_metadata.media_type = "episode") ' \ + ' OR session_history_metadata.media_type = "episode") %s ' \ ' GROUP BY %s) AS t ' \ 'WHERE percent_complete >= %s ' \ 'GROUP BY t.id ' \ 'ORDER BY last_watch DESC ' \ - 'LIMIT %s' % (time_range, group_by, notify_watched_percent, stats_count) + 'LIMIT %s' % (time_range, library_cond, group_by, notify_watched_percent, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: last_watched: %s." % e) @@ -567,13 +592,14 @@ class DataFactory(object): 'rating_key': item['rating_key'], 'thumb': thumb, 'grandparent_thumb': item['grandparent_thumb'], + 'section_id': item['section_id'], 'last_watch': item['last_watch'], 'player': item['player'] } last_watched.append(row) home_stats.append({'stat_id': stat, - 'rows': helpers.filter_session_info(last_watched, 'user_id')}) + 'rows': session.mask_session_info(last_watched, mask_metadata=True)}) elif stat == 'most_concurrent': @@ -692,19 +718,24 @@ class DataFactory(object): } library_stats.append(library) - return helpers.filter_session_info(library_stats, 'section_id') + return session.filter_session_info(library_stats, 'section_id') def get_stream_details(self, row_id=None): monitor_db = database.MonitorDatabase() + user_cond = '' + if session.get_session_user_id(): + user_cond = 'AND session_history.user_id = %s ' % session.get_session_user_id() + if row_id: query = 'SELECT container, bitrate, video_resolution, width, height, aspect_ratio, video_framerate, ' \ 'video_codec, audio_codec, audio_channels, video_decision, transcode_video_codec, transcode_height, ' \ - 'transcode_width, audio_decision, transcode_audio_codec, transcode_audio_channels, media_type, ' \ - 'title, grandparent_title ' \ - 'from session_history_media_info ' \ - 'join session_history_metadata on session_history_media_info.id = session_history_metadata.id ' \ - 'where session_history_media_info.id = ?' + 'transcode_width, audio_decision, transcode_audio_codec, transcode_audio_channels, ' \ + 'session_history_metadata.media_type, title, grandparent_title ' \ + 'FROM session_history_media_info ' \ + 'JOIN session_history ON session_history_media_info.id = session_history.id ' \ + 'JOIN session_history_metadata ON session_history_media_info.id = session_history_metadata.id ' \ + 'WHERE session_history_media_info.id = ? %s' % user_cond result = monitor_db.select(query, args=[row_id]) else: return None diff --git a/plexpy/datatables.py b/plexpy/datatables.py index 6c0a9b98..88275b3c 100644 --- a/plexpy/datatables.py +++ b/plexpy/datatables.py @@ -89,16 +89,15 @@ class DataTables(object): # Build custom where parameters if custom_where: for w in custom_where: - c_where += w[0] + ' = ? AND ' - - # The order of our args changes if we are grouping - #if grouping: - # args.insert(0, w[1]) - #else: - # args.append(w[1]) - - # My testing shows that order of args doesn't change - args.append(w[1]) + if isinstance(w[1], (list, tuple)) and len(w[1]): + c_where += '(' + for w_ in w[1]: + c_where += w[0] + ' = ? OR ' + args.append(w_) + c_where = c_where.rstrip(' OR ') + ') AND ' + else: + c_where += w[0] + ' = ? AND ' + args.append(w[1]) if c_where: c_where = 'WHERE ' + c_where.rstrip(' AND ') diff --git a/plexpy/helpers.py b/plexpy/helpers.py index 334beed3..c028284e 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -563,104 +563,4 @@ def uploadToImgur(imgPath, imgTitle=''): except (urllib2.HTTPError, urllib2.URLError) as e: logger.warn(u"PlexPy Helpers :: Unable to upload image to Imgur: %s" % e) - return img_url - - -def allow_session_user(user_id): - """ - Returns True or False if the user_id is allowed for the user session - """ - import cherrypy - from plexpy.webauth import SESSION_KEY - - if cherrypy.config.get('tools.auth.on'): - _session = cherrypy.session.get(SESSION_KEY) - if str(user_id) != _session['user_id']: - return False - - return True - -def allow_session_library(section_id): - """ - Returns True or False if the section_id is allowed for the user session - """ - import cherrypy - from plexpy.webauth import SESSION_KEY - - if cherrypy.config.get('tools.auth.on'): - _session = cherrypy.session.get(SESSION_KEY) - if str(section_id) not in _session['user_libraries']: - return False - - return True - -def filter_session_info(list_of_dicts, filter_key=None): - """ - Filters a list of dictionary items to only return the info for the current logged in user - """ - import cherrypy - from plexpy.webauth import SESSION_KEY - - if cherrypy.config.get('tools.auth.on'): - _session = cherrypy.session.get(SESSION_KEY) - - if filter_key == 'user_id' and _session['user_id']: - session_user_id = str(_session['user_id']) - return [d for d in list_of_dicts if str(d.get('user_id','')) == session_user_id] - - elif filter_key == 'section_id' and _session['user_libraries']: - session_library_ids = _session['user_libraries'] - return [d for d in list_of_dicts if str(d.get('section_id','')) in session_library_ids] - - return list_of_dicts - -def mask_session_info(list_of_dicts, mask_metadata=False): - """ - Masks user info in a list of dictionary items to only display info for the current logged in user - """ - import cherrypy - from plexpy.webauth import SESSION_KEY - - if cherrypy.config.get('tools.auth.on'): - _session = cherrypy.session.get(SESSION_KEY) - - keys_to_mask = {'user_id': '', - 'user': '', - 'friendly_name': 'Plex User', - 'user_thumb': common.DEFAULT_USER_THUMB, - 'ip_address': 'N/A', - 'machine_id': '' - } - - metadata_to_mask = {'media_index': '', - 'parent_media_index': '', - 'art': common.DEFAULT_ART, - 'parent_thumb': common.DEFAULT_POSTER_THUMB, - 'grandparent_thumb': common.DEFAULT_POSTER_THUMB, - 'thumb': common.DEFAULT_POSTER_THUMB, - 'bif_thumb': '', - 'grandparent_title': '', - 'parent_title': '', - 'title': '', - 'rating_key': '', - 'parent_rating_key': '', - 'grandparent_rating_key': '', - 'year': '' - } - - if _session['user_id']: - session_user_id = str(_session['user_id']) - session_library_ids = _session['user_libraries'] - - for d in list_of_dicts: - if not (str(d.get('user_id')) == session_user_id or d.get('user') == _session['user']): - for k, v in keys_to_mask.iteritems(): - if k in d: d[k] = keys_to_mask[k] - - if mask_metadata and str(d.get('section_id','')) not in session_library_ids: - for k, v in metadata_to_mask.iteritems(): - if k in d: d[k] = metadata_to_mask[k] - - return list_of_dicts - - return list_of_dicts \ No newline at end of file + return img_url \ No newline at end of file diff --git a/plexpy/libraries.py b/plexpy/libraries.py index 88997e31..a3d952c6 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -13,8 +13,8 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, datatables, common, database, helpers import plexpy +from plexpy import logger, datatables, common, database, helpers, session def update_section_ids(): from plexpy import pmsconnect, activity_pinger @@ -119,9 +119,18 @@ class Libraries(object): pass def get_datatables_list(self, kwargs=None): + default_return = {'recordsFiltered': 0, + 'recordsTotal': 0, + 'draw': 0, + 'data': 'null', + 'error': 'Unable to execute database query.'} + data_tables = datatables.DataTables() - custom_where = ['library_sections.deleted_section', 0] + custom_where = [['library_sections.deleted_section', 0]] + + if session.get_session_libraries(): + custom_where.append(['library_sections.section_id', session.get_session_libraries()]) columns = ['library_sections.section_id', 'library_sections.section_name', @@ -155,7 +164,7 @@ class Libraries(object): try: query = data_tables.ssp_query(table_name='library_sections', columns=columns, - custom_where=[custom_where], + custom_where=custom_where, group_by=['library_sections.server_id', 'library_sections.section_id'], join_types=['LEFT OUTER JOIN', 'LEFT OUTER JOIN', @@ -169,11 +178,7 @@ class Libraries(object): kwargs=kwargs) except Exception as e: logger.warn(u"PlexPy Libraries :: Unable to execute database query for get_list: %s." % e) - return {'recordsFiltered': 0, - 'recordsTotal': 0, - 'draw': 0, - 'data': 'null', - 'error': 'Unable to execute database query.'} + return default_return result = query['result'] @@ -222,7 +227,7 @@ class Libraries(object): dict = {'recordsFiltered': query['filteredCount'], 'recordsTotal': query['totalCount'], - 'data': helpers.filter_session_info(rows, 'section_id'), + 'data': rows, 'draw': query['draw'] } @@ -235,9 +240,12 @@ class Libraries(object): default_return = {'recordsFiltered': 0, 'recordsTotal': 0, 'draw': 0, - 'data': None, + 'data': 'null', 'error': 'Unable to execute database query.'} + if not session.allow_session_library(section_id): + return default_return + if section_id and not str(section_id).isdigit(): logger.warn(u"PlexPy Libraries :: Datatable media info called by invalid section_id provided.") return default_return @@ -443,6 +451,9 @@ class Libraries(object): from plexpy import pmsconnect import json, os + if not session.allow_session_library(section_id): + return False + if section_id and not str(section_id).isdigit(): logger.warn(u"PlexPy Libraries :: Datatable media info file size called by invalid section_id provided.") return False @@ -619,6 +630,9 @@ class Libraries(object): return default_return def get_watch_time_stats(self, section_id=None): + if not session.allow_session_library(section_id): + return [] + monitor_db = database.MonitorDatabase() time_queries = [1, 7, 30, 0] @@ -671,6 +685,9 @@ class Libraries(object): return library_watch_time_stats def get_user_stats(self, section_id=None): + if not session.allow_session_library(section_id): + return [] + monitor_db = database.MonitorDatabase() user_stats = [] @@ -678,7 +695,7 @@ class Libraries(object): try: if str(section_id).isdigit(): query = 'SELECT (CASE WHEN users.friendly_name IS NULL THEN users.username ' \ - 'ELSE users.friendly_name END) AS user, users.user_id, users.thumb, COUNT(user) AS user_count ' \ + 'ELSE users.friendly_name END) AS friendly_name, users.user_id, users.thumb, COUNT(user) AS user_count ' \ 'FROM session_history ' \ 'JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ 'JOIN users ON users.user_id = session_history.user_id ' \ @@ -693,16 +710,19 @@ class Libraries(object): result = [] for item in result: - row = {'user': item['user'], + row = {'friendly_name': item['friendly_name'], 'user_id': item['user_id'], - 'thumb': item['thumb'], + 'user_thumb': item['thumb'], 'total_plays': item['user_count'] } user_stats.append(row) - return helpers.filter_session_info(user_stats, 'user_id') + return session.mask_session_info(user_stats) def get_recently_watched(self, section_id=None, limit='10'): + if not session.allow_session_library(section_id): + return [] + monitor_db = database.MonitorDatabase() recently_watched = [] diff --git a/plexpy/plextv.py b/plexpy/plextv.py index 13864a97..5e89001d 100644 --- a/plexpy/plextv.py +++ b/plexpy/plextv.py @@ -16,14 +16,13 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, helpers, http_handler, database, users -import xmltodict -import json -from xml.dom import minidom -import requests - import base64 +import json +import xmltodict +from xml.dom import minidom + import plexpy +from plexpy import logger, helpers, http_handler, database, users, session def refresh_users(): @@ -401,7 +400,7 @@ class PlexTV(object): synced_items.append(sync_details) - return helpers.filter_session_info(synced_items, 'user_id') + return session.filter_session_info(synced_items, 'user_id') def get_server_urls(self, include_https=True): diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py index 35d0255f..a8949b40 100644 --- a/plexpy/pmsconnect.py +++ b/plexpy/pmsconnect.py @@ -13,11 +13,11 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, helpers, users, http_handler, common, database +import urllib2 from urlparse import urlparse import plexpy -import urllib2 +from plexpy import logger, helpers, users, http_handler, common, database, session def get_server_friendly_name(): @@ -462,7 +462,7 @@ class PmsConnect(object): 'grandparent_title': helpers.get_xml_attr(item, 'grandparentTitle'), 'media_index': helpers.get_xml_attr(item, 'index'), 'parent_media_index': helpers.get_xml_attr(item, 'parentIndex'), - 'section_id': helpers.get_xml_attr(item, 'librarySectionID'), + 'section_id': section_id if section_id else helpers.get_xml_attr(item, 'librarySectionID'), 'library_name': helpers.get_xml_attr(item, 'librarySectionTitle'), 'year': helpers.get_xml_attr(item, 'year'), 'thumb': helpers.get_xml_attr(item, 'thumb'), @@ -484,7 +484,7 @@ class PmsConnect(object): 'grandparent_title': helpers.get_xml_attr(item, 'grandparentTitle'), 'media_index': helpers.get_xml_attr(item, 'index'), 'parent_media_index': helpers.get_xml_attr(item, 'parentIndex'), - 'section_id': helpers.get_xml_attr(item, 'librarySectionID'), + 'section_id': section_id if section_id else helpers.get_xml_attr(item, 'librarySectionID'), 'library_name': helpers.get_xml_attr(item, 'librarySectionTitle'), 'year': helpers.get_xml_attr(item, 'year'), 'thumb': helpers.get_xml_attr(item, 'thumb'), @@ -493,9 +493,10 @@ class PmsConnect(object): 'added_at': helpers.get_xml_attr(item, 'addedAt') } recents_list.append(recent_items) - - output = {'recently_added': helpers.filter_session_info( + + output = {'recently_added': session.filter_session_info( sorted(recents_list, key=lambda k: k['added_at'], reverse=True), 'section_id')} + return output def get_metadata_details(self, rating_key='', get_media_info=False): @@ -975,7 +976,7 @@ class PmsConnect(object): session_list.append(session_output) output = {'stream_count': helpers.get_xml_attr(xml_head[0], 'size'), - 'sessions': helpers.mask_session_info(session_list, True) + 'sessions': session.mask_session_info(session_list, True) } return output diff --git a/plexpy/session.py b/plexpy/session.py new file mode 100644 index 00000000..1b49fbe4 --- /dev/null +++ b/plexpy/session.py @@ -0,0 +1,150 @@ +# This file is part of PlexPy. +# +# PlexPy 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. +# +# PlexPy 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 PlexPy. If not, see . + +import cherrypy + +from plexpy import common + + +def get_session_info(): + """ + Returns the session info for the user session + """ + from plexpy.webauth import SESSION_KEY + + if cherrypy.config.get('tools.auth.on'): + _session = cherrypy.session.get(SESSION_KEY) + if _session: + return _session + + return {'user_id': None, + 'user': None, + 'user_group': 'admin', + 'user_libraries': None, + 'expiry': None} + +def get_session_user(): + """ + Returns the user_id for the current logged in session + """ + _session = get_session_info() + return _session['user'] + +def get_session_user_id(): + """ + Returns the user_id for the current logged in session + """ + _session = get_session_info() + return str(_session['user_id']) if _session['user_id'] else None + +def get_session_libraries(): + """ + Returns a tuple of section_id for the current logged in session + """ + _session = get_session_info() + return _session['user_libraries'] + +def allow_session_user(user_id): + """ + Returns True or False if the user_id is allowed for the current logged in session + """ + session_user_id = get_session_user_id() + if session_user_id and str(user_id) != session_user_id: + return False + return True + +def allow_session_library(section_id): + """ + Returns True or False if the section_id is allowed for the current logged in session + """ + session_library_ids = get_session_libraries() + if session_library_ids and str(section_id) not in session_library_ids: + return False + return True + +def filter_session_info(list_of_dicts, filter_key=None): + """ + Filters a list of dictionary items to only return the info for the current logged in session + """ + session_user_id = get_session_user_id() + session_library_ids = get_session_libraries() + + list_of_dicts = friendly_name_to_username(list_of_dicts) + + if filter_key == 'user_id' and session_user_id: + return [d for d in list_of_dicts if str(d.get('user_id','')) == session_user_id] + + elif filter_key == 'section_id' and session_library_ids: + return [d for d in list_of_dicts if str(d.get('section_id','')) in session_library_ids] + + return list_of_dicts + +def mask_session_info(list_of_dicts, mask_metadata=False): + """ + Masks user info in a list of dictionary items to only display info for the current logged in session + """ + session_user = get_session_user() + session_user_id = get_session_user_id() + session_library_ids = get_session_libraries() + + keys_to_mask = {'user_id': '', + 'user': 'Plex User', + 'friendly_name': 'Plex User', + 'user_thumb': common.DEFAULT_USER_THUMB, + 'ip_address': 'N/A', + 'machine_id': '', + 'player': 'Player' + } + + metadata_to_mask = {'media_index': '', + 'parent_media_index': '', + 'art': common.DEFAULT_ART, + 'parent_thumb': common.DEFAULT_POSTER_THUMB, + 'grandparent_thumb': common.DEFAULT_POSTER_THUMB, + 'thumb': common.DEFAULT_POSTER_THUMB, + 'bif_thumb': '', + 'grandparent_title': '', + 'parent_title': '', + 'title': '', + 'rating_key': '', + 'parent_rating_key': '', + 'grandparent_rating_key': '', + 'year': '' + } + + list_of_dicts = friendly_name_to_username(list_of_dicts) + + for d in list_of_dicts: + if session_user_id and not (str(d.get('user_id')) == session_user_id or d.get('user') == session_user): + for k, v in keys_to_mask.iteritems(): + if k in d: d[k] = keys_to_mask[k] + + if mask_metadata and session_library_ids and str(d.get('section_id','')) not in session_library_ids: + for k, v in metadata_to_mask.iteritems(): + if k in d: d[k] = metadata_to_mask[k] + + return list_of_dicts + +def friendly_name_to_username(list_of_dicts): + """ + Reverts the friendly name back to the username of the current logged in session + """ + session_user = get_session_user() + + for d in list_of_dicts: + if 'friendly_name' in d and d['friendly_name'] != session_user: + d['friendly_name'] = session_user + + return list_of_dicts \ No newline at end of file diff --git a/plexpy/users.py b/plexpy/users.py index 9ef40ecb..47aa814b 100644 --- a/plexpy/users.py +++ b/plexpy/users.py @@ -13,7 +13,8 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, datatables, common, database, helpers +import plexpy +from plexpy import logger, datatables, common, database, helpers, session def user_login(username=None, password=None): from plexpy import plextv @@ -85,9 +86,18 @@ class Users(object): pass def get_datatables_list(self, kwargs=None): + default_return = {'recordsFiltered': 0, + 'recordsTotal': 0, + 'draw': 0, + 'data': 'null', + 'error': 'Unable to execute database query.'} + data_tables = datatables.DataTables() - custom_where = ['users.deleted_user', 0] + custom_where = [['users.deleted_user', 0]] + + if session.get_session_user_id(): + custom_where.append(['users.user_id', session.get_session_user_id()]) columns = ['users.user_id', '(CASE WHEN users.friendly_name IS NULL OR TRIM(users.friendly_name) = "" \ @@ -121,7 +131,7 @@ class Users(object): try: query = data_tables.ssp_query(table_name='users', columns=columns, - custom_where=[custom_where], + custom_where=custom_where, group_by=['users.user_id'], join_types=['LEFT OUTER JOIN', 'LEFT OUTER JOIN', @@ -135,11 +145,7 @@ class Users(object): kwargs=kwargs) except Exception as e: logger.warn(u"PlexPy Users :: Unable to execute database query for get_list: %s." % e) - return {'recordsFiltered': 0, - 'recordsTotal': 0, - 'draw': 0, - 'data': 'null', - 'error': 'Unable to execute database query.'} + return default_return users = query['result'] @@ -190,13 +196,22 @@ class Users(object): dict = {'recordsFiltered': query['filteredCount'], 'recordsTotal': query['totalCount'], - 'data': helpers.filter_session_info(rows, 'user_id'), + 'data': session.friendly_name_to_username(rows), 'draw': query['draw'] } return dict def get_datatables_unique_ips(self, user_id=None, kwargs=None): + default_return = {'recordsFiltered': 0, + 'recordsTotal': 0, + 'draw': 0, + 'data': 'null', + 'error': 'Unable to execute database query.'} + + if not session.allow_session_user(user_id): + return default_return + data_tables = datatables.DataTables() custom_where = ['users.user_id', user_id] @@ -241,11 +256,7 @@ class Users(object): kwargs=kwargs) except Exception as e: logger.warn(u"PlexPy Users :: Unable to execute database query for get_unique_ips: %s." % e) - return {'recordsFiltered': 0, - 'recordsTotal': 0, - 'draw': 0, - 'data': 'null', - 'error': 'Unable to execute database query.'} + return default_return results = query['result'] @@ -284,7 +295,7 @@ class Users(object): dict = {'recordsFiltered': query['filteredCount'], 'recordsTotal': query['totalCount'], - 'data': helpers.filter_session_info(rows, 'user_id'), + 'data': session.friendly_name_to_username(rows), 'draw': query['draw'] } @@ -356,7 +367,9 @@ class Users(object): user_details = {} if result: for item in result: - if item['friendly_name']: + if session.get_session_user(): + friendly_name = session.get_session_user() + elif item['friendly_name']: friendly_name = item['friendly_name'] else: friendly_name = item['username'] @@ -407,6 +420,9 @@ class Users(object): return default_return def get_watch_time_stats(self, user_id=None): + if not session.allow_session_user(user_id): + return [] + monitor_db = database.MonitorDatabase() time_queries = [1, 7, 30, 0] @@ -457,6 +473,9 @@ class Users(object): return user_watch_time_stats def get_player_stats(self, user_id=None): + if not session.allow_session_user(user_id): + return [] + monitor_db = database.MonitorDatabase() player_stats = [] @@ -491,6 +510,9 @@ class Users(object): return player_stats def get_recently_watched(self, user_id=None, limit='10'): + if not session.allow_session_user(user_id): + return [] + monitor_db = database.MonitorDatabase() recently_watched = [] diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 0d1f6449..3fa0d243 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -13,32 +13,24 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, notifiers, plextv, pmsconnect, common, log_reader, \ - datafactory, graphs, users, libraries, database, web_socket -from plexpy.helpers import checked, addtoapi, get_ip, create_https_certificates -from plexpy.webauth import AuthController, requireAuth, member_of, name_is, SESSION_KEY - -from mako.lookup import TemplateLookup -from mako import exceptions -from hashing_passwords import make_hash - -import plexpy -import threading import cherrypy import hashlib -import random import json import os -from api2 import API2 +import random +import threading -try: - # pylint:disable=E0611 - # ignore this error because we are catching the ImportError - from collections import OrderedDict - # pylint:enable=E0611 -except ImportError: - # Python 2.6.x fallback, from libs - from ordereddict import OrderedDict +from hashing_passwords import make_hash +from mako.lookup import TemplateLookup +from mako import exceptions + +import plexpy +from plexpy import logger, notifiers, plextv, pmsconnect, common, log_reader, \ + datafactory, graphs, users, libraries, database, web_socket +from plexpy.api2 import API2 +from plexpy.helpers import checked, addtoapi, get_ip, create_https_certificates +from plexpy.session import get_session_info, allow_session_user, allow_session_library +from plexpy.webauth import AuthController, requireAuth, member_of, name_is, SESSION_KEY def serve_template(templatename, **kwargs): @@ -49,13 +41,7 @@ def serve_template(templatename, **kwargs): server_name = plexpy.CONFIG.PMS_NAME - _session = {'user_id': None, - 'user': None, - 'user_group': 'admin', - 'expiry': None} - - if cherrypy.config.get('tools.auth.on'): - _session = cherrypy.session.get(SESSION_KEY) + _session = get_session_info() try: template = _hplookup.get_template(templatename) @@ -351,6 +337,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def library(self, section_id=None): + if not allow_session_library(section_id): + raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + config = { "get_file_sizes": plexpy.CONFIG.GET_FILE_SIZES, "get_file_sizes_hold": plexpy.CONFIG.GET_FILE_SIZES_HOLD @@ -409,6 +398,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def get_library_watch_time_stats(self, section_id=None, **kwargs): + if not allow_session_library(section_id): + return serve_template(templatename="user_watch_time_stats.html", data=None, title="Watch Stats") + if section_id: library_data = libraries.Libraries() result = library_data.get_watch_time_stats(section_id=section_id) @@ -424,6 +416,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def get_library_user_stats(self, section_id=None, **kwargs): + if not allow_session_library(section_id): + return serve_template(templatename="library_user_stats.html", data=None, title="Player Stats") + if section_id: library_data = libraries.Libraries() result = library_data.get_user_stats(section_id=section_id) @@ -439,6 +434,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def get_library_recently_watched(self, section_id=None, limit='10', **kwargs): + if not allow_session_library(section_id): + return serve_template(templatename="user_recently_watched.html", data=None, title="Recently Watched") + if section_id: library_data = libraries.Libraries() result = library_data.get_recently_watched(section_id=section_id, limit=limit) @@ -454,6 +452,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def get_library_recently_added(self, section_id=None, limit='10', **kwargs): + if not allow_session_library(section_id): + return serve_template(templatename="library_recently_added.html", data=None, title="Recently Added") + if section_id: pms_connect = pmsconnect.PmsConnect() result = pms_connect.get_recently_added_details(section_id=section_id, count=limit) @@ -649,6 +650,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def user(self, user_id=None): + if not allow_session_user(user_id): + raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + user_data = users.Users() if user_id: try: @@ -702,6 +706,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def get_user_watch_time_stats(self, user=None, user_id=None, **kwargs): + if not allow_session_user(user_id): + return serve_template(templatename="user_watch_time_stats.html", data=None, title="Watch Stats") + if user_id or user: user_data = users.Users() result = user_data.get_watch_time_stats(user_id=user_id) @@ -717,6 +724,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def get_user_player_stats(self, user=None, user_id=None, **kwargs): + if not allow_session_user(user_id): + return serve_template(templatename="user_player_stats.html", data=None, title="Player Stats") + if user_id or user: user_data = users.Users() result = user_data.get_player_stats(user_id=user_id) @@ -732,6 +742,9 @@ class WebInterface(object): @cherrypy.expose @requireAuth() def get_user_recently_watched(self, user=None, user_id=None, limit='10', **kwargs): + if not allow_session_user(user_id): + return serve_template(templatename="user_recently_watched.html", data=None, title="Recently Watched") + if user_id or user: user_data = users.Users() result = user_data.get_recently_watched(user_id=user_id, limit=limit) @@ -1239,10 +1252,10 @@ class WebInterface(object): def log_js_errors(self, page, message, file, line): """ Logs javascript errors from the web interface. """ logger.error(u"WebUI :: /%s : %s. (%s:%s)" % (page.rpartition('/')[-1], - message, - file.rpartition('/')[-1].partition('?')[0], - line)) - return True + message, + file.rpartition('/')[-1].partition('?')[0], + line)) + return "js error logged." @cherrypy.expose @requireAuth(member_of("admin")) From 545dd085350372583257401ccaa97ba40e93fcb7 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Tue, 26 Apr 2016 00:09:46 -0700 Subject: [PATCH 025/132] Flip mask_session_info bool --- plexpy/datafactory.py | 20 ++++++++++---------- plexpy/libraries.py | 2 +- plexpy/plextv.py | 2 +- plexpy/pmsconnect.py | 4 ++-- plexpy/session.py | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py index f94fa111..23c97b75 100644 --- a/plexpy/datafactory.py +++ b/plexpy/datafactory.py @@ -231,7 +231,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': session.mask_session_info(top_tv, mask_metadata=True)}) + 'rows': session.mask_session_info(top_tv)}) elif stat == 'popular_tv': popular_tv = [] @@ -274,7 +274,7 @@ class DataFactory(object): popular_tv.append(row) home_stats.append({'stat_id': stat, - 'rows': session.mask_session_info(popular_tv, mask_metadata=True)}) + 'rows': session.mask_session_info(popular_tv)}) elif stat == 'top_movies': top_movies = [] @@ -318,7 +318,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': session.mask_session_info(top_movies, mask_metadata=True)}) + 'rows': session.mask_session_info(top_movies)}) elif stat == 'popular_movies': popular_movies = [] @@ -361,7 +361,7 @@ class DataFactory(object): popular_movies.append(row) home_stats.append({'stat_id': stat, - 'rows': session.mask_session_info(popular_movies, mask_metadata=True)}) + 'rows': session.mask_session_info(popular_movies)}) elif stat == 'top_music': top_music = [] @@ -405,7 +405,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': session.mask_session_info(top_music, mask_metadata=True)}) + 'rows': session.mask_session_info(top_music)}) elif stat == 'popular_music': popular_music = [] @@ -448,7 +448,7 @@ class DataFactory(object): popular_music.append(row) home_stats.append({'stat_id': stat, - 'rows': session.mask_session_info(popular_music, mask_metadata=True)}) + 'rows': session.mask_session_info(popular_music)}) elif stat == 'top_users': top_users = [] @@ -501,7 +501,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': session.mask_session_info(top_users, mask_metadata=True)}) + 'rows': session.mask_session_info(top_users)}) elif stat == 'top_platforms': top_platform = [] @@ -547,7 +547,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': session.mask_session_info(top_platform, mask_metadata=True)}) + 'rows': session.mask_session_info(top_platform)}) elif stat == 'last_watched': last_watched = [] @@ -599,7 +599,7 @@ class DataFactory(object): last_watched.append(row) home_stats.append({'stat_id': stat, - 'rows': session.mask_session_info(last_watched, mask_metadata=True)}) + 'rows': session.mask_session_info(last_watched)}) elif stat == 'most_concurrent': @@ -718,7 +718,7 @@ class DataFactory(object): } library_stats.append(library) - return session.filter_session_info(library_stats, 'section_id') + return session.filter_session_info(library_stats, filter_key='section_id') def get_stream_details(self, row_id=None): monitor_db = database.MonitorDatabase() diff --git a/plexpy/libraries.py b/plexpy/libraries.py index a3d952c6..785767f8 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -717,7 +717,7 @@ class Libraries(object): } user_stats.append(row) - return session.mask_session_info(user_stats) + return session.mask_session_info(user_stats, mask_metadata=False) def get_recently_watched(self, section_id=None, limit='10'): if not session.allow_session_library(section_id): diff --git a/plexpy/plextv.py b/plexpy/plextv.py index 5e89001d..4dae8f92 100644 --- a/plexpy/plextv.py +++ b/plexpy/plextv.py @@ -400,7 +400,7 @@ class PlexTV(object): synced_items.append(sync_details) - return session.filter_session_info(synced_items, 'user_id') + return session.filter_session_info(synced_items, filter_key='user_id') def get_server_urls(self, include_https=True): diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py index a8949b40..44e8a880 100644 --- a/plexpy/pmsconnect.py +++ b/plexpy/pmsconnect.py @@ -495,7 +495,7 @@ class PmsConnect(object): recents_list.append(recent_items) output = {'recently_added': session.filter_session_info( - sorted(recents_list, key=lambda k: k['added_at'], reverse=True), 'section_id')} + sorted(recents_list, key=lambda k: k['added_at'], reverse=True), filter_key='section_id')} return output @@ -976,7 +976,7 @@ class PmsConnect(object): session_list.append(session_output) output = {'stream_count': helpers.get_xml_attr(xml_head[0], 'size'), - 'sessions': session.mask_session_info(session_list, True) + 'sessions': session.mask_session_info(session_list) } return output diff --git a/plexpy/session.py b/plexpy/session.py index 1b49fbe4..7ae4ff12 100644 --- a/plexpy/session.py +++ b/plexpy/session.py @@ -91,7 +91,7 @@ def filter_session_info(list_of_dicts, filter_key=None): return list_of_dicts -def mask_session_info(list_of_dicts, mask_metadata=False): +def mask_session_info(list_of_dicts, mask_metadata=True): """ Masks user info in a list of dictionary items to only display info for the current logged in session """ From c8b13ff5e1a71ae699a000f553fdda56348ecacc Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Tue, 26 Apr 2016 20:18:49 -0700 Subject: [PATCH 026/132] Filter all graph data for guests --- data/interfaces/default/settings.html | 2 +- plexpy/datafactory.py | 16 ++--- plexpy/graphs.py | 84 ++++++++++++++++++++++----- plexpy/webserve.py | 2 + plexpy/webstart.py | 8 +-- 5 files changed, 83 insertions(+), 29 deletions(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index a5d1f29e..747034e1 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -418,7 +418,7 @@ available_notification_agents = sorted(notifiers.available_notification_agents()
    • -

      The base URL of the web server used for reverse proxies.

      +

      The base URL of the web server. Used for reverse proxies.

    + +
    +

    + ${item['title']} +

    +
    + % endif % elif item['media_type'] == 'track': + % if item['rating_key']:
    + % else: +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    + ${item['title']} +

    +
    + % endif % endif % endfor diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py index 1e979cd2..7beec862 100644 --- a/plexpy/datafactory.py +++ b/plexpy/datafactory.py @@ -179,13 +179,6 @@ class DataFactory(object): group_by = 'session_history.reference_id' if grouping else 'session_history.id' sort_type = 'total_plays' if stats_type == 0 else 'total_duration' - library_cond = '' - if session.get_session_libraries(): - library_cond = 'AND (' - for section_id in session.get_session_libraries(): - library_cond += 'session_history_metadata.section_id = %s OR ' % section_id - library_cond = library_cond.rstrip(' OR ') + ') ' - home_stats = [] for stat in stats_cards: @@ -193,6 +186,7 @@ class DataFactory(object): top_tv = [] try: query = 'SELECT t.id, t.grandparent_title, t.grandparent_rating_key, t.grandparent_thumb, t.section_id, ' \ + 't.media_type, t.content_rating, t.labels, ' \ 'MAX(t.started) AS last_watch, COUNT(t.id) AS total_plays, SUM(t.d) AS total_duration ' \ 'FROM (SELECT *, SUM(CASE WHEN stopped > 0 THEN (stopped - started) - ' \ ' (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) ' \ @@ -201,11 +195,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "episode" %s' \ + ' AND session_history.media_type = "episode" ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.grandparent_title ' \ 'ORDER BY %s DESC ' \ - 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: top_tv: %s." % e) @@ -221,6 +215,9 @@ class DataFactory(object): 'grandparent_thumb': item['grandparent_thumb'], 'thumb': '', 'section_id': item['section_id'], + 'media_type': item['media_type'], + 'content_rating': item['content_rating'], + 'labels': item['labels'].split(';') if item['labels'] else (), 'user': '', 'friendly_name': '', 'platform_type': '', @@ -237,6 +234,7 @@ class DataFactory(object): popular_tv = [] try: query = 'SELECT t.id, t.grandparent_title, t.grandparent_rating_key, t.grandparent_thumb, t.section_id, ' \ + 't.media_type, t.content_rating, t.labels, ' \ 'COUNT(DISTINCT t.user_id) AS users_watched, ' \ 'MAX(t.started) AS last_watch, COUNT(t.id) as total_plays, SUM(t.d) AS total_duration ' \ 'FROM (SELECT *, SUM(CASE WHEN stopped > 0 THEN (stopped - started) - ' \ @@ -246,11 +244,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "episode" %s' \ + ' AND session_history.media_type = "episode" ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.grandparent_title ' \ 'ORDER BY users_watched DESC, %s DESC ' \ - 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: popular_tv: %s." % e) @@ -265,6 +263,9 @@ class DataFactory(object): 'grandparent_thumb': item['grandparent_thumb'], 'thumb': '', 'section_id': item['section_id'], + 'media_type': item['media_type'], + 'content_rating': item['content_rating'], + 'labels': item['labels'].split(';') if item['labels'] else (), 'user': '', 'friendly_name': '', 'platform_type': '', @@ -280,6 +281,7 @@ class DataFactory(object): top_movies = [] try: query = 'SELECT t.id, t.full_title, t.rating_key, t.thumb, t.section_id, ' \ + 't.media_type, t.content_rating, t.labels, ' \ 'MAX(t.started) AS last_watch, COUNT(t.id) AS total_plays, SUM(t.d) AS total_duration ' \ 'FROM (SELECT *, SUM(CASE WHEN stopped > 0 THEN (stopped - started) - ' \ ' (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) ' \ @@ -288,11 +290,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "movie" %s' \ + ' AND session_history.media_type = "movie" ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.full_title ' \ 'ORDER BY %s DESC ' \ - 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: top_movies: %s." % e) @@ -308,6 +310,9 @@ class DataFactory(object): 'grandparent_thumb': '', 'thumb': item['thumb'], 'section_id': item['section_id'], + 'media_type': item['media_type'], + 'content_rating': item['content_rating'], + 'labels': item['labels'].split(';') if item['labels'] else (), 'user': '', 'friendly_name': '', 'platform_type': '', @@ -324,6 +329,7 @@ class DataFactory(object): popular_movies = [] try: query = 'SELECT t.id, t.full_title, t.rating_key, t.thumb, t.section_id, ' \ + 't.media_type, t.content_rating, t.labels, ' \ 'COUNT(DISTINCT t.user_id) AS users_watched, ' \ 'MAX(t.started) AS last_watch, COUNT(t.id) as total_plays, SUM(t.d) AS total_duration ' \ 'FROM (SELECT *, SUM(CASE WHEN stopped > 0 THEN (stopped - started) - ' \ @@ -333,11 +339,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "movie" %s' \ + ' AND session_history.media_type = "movie" ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.full_title ' \ 'ORDER BY users_watched DESC, %s DESC ' \ - 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: popular_movies: %s." % e) @@ -352,6 +358,9 @@ class DataFactory(object): 'grandparent_thumb': '', 'thumb': item['thumb'], 'section_id': item['section_id'], + 'media_type': item['media_type'], + 'content_rating': item['content_rating'], + 'labels': item['labels'].split(';') if item['labels'] else (), 'user': '', 'friendly_name': '', 'platform_type': '', @@ -367,6 +376,7 @@ class DataFactory(object): top_music = [] try: query = 'SELECT t.id, t.grandparent_title, t.grandparent_rating_key, t.grandparent_thumb, t.section_id, ' \ + 't.media_type, t.content_rating, t.labels, ' \ 'MAX(t.started) AS last_watch, COUNT(t.id) AS total_plays, SUM(t.d) AS total_duration ' \ 'FROM (SELECT *, SUM(CASE WHEN stopped > 0 THEN (stopped - started) - ' \ ' (CASE WHEN paused_counter IS NULL THEN 0 ELSE paused_counter END) ELSE 0 END) ' \ @@ -375,11 +385,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "track" %s' \ + ' AND session_history.media_type = "track" ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.grandparent_title ' \ 'ORDER BY %s DESC ' \ - 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: top_music: %s." % e) @@ -395,6 +405,9 @@ class DataFactory(object): 'grandparent_thumb': item['grandparent_thumb'], 'thumb': '', 'section_id': item['section_id'], + 'media_type': item['media_type'], + 'content_rating': item['content_rating'], + 'labels': item['labels'].split(';') if item['labels'] else (), 'user': '', 'friendly_name': '', 'platform_type': '', @@ -411,6 +424,7 @@ class DataFactory(object): popular_music = [] try: query = 'SELECT t.id, t.grandparent_title, t.grandparent_rating_key, t.grandparent_thumb, t.section_id, ' \ + 't.media_type, t.content_rating, t.labels, ' \ 'COUNT(DISTINCT t.user_id) AS users_watched, ' \ 'MAX(t.started) AS last_watch, COUNT(t.id) as total_plays, SUM(t.d) AS total_duration ' \ 'FROM (SELECT *, SUM(CASE WHEN stopped > 0 THEN (stopped - started) - ' \ @@ -420,11 +434,11 @@ class DataFactory(object): ' JOIN session_history_metadata ON session_history_metadata.id = session_history.id ' \ ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ - ' AND session_history.media_type = "track" %s' \ + ' AND session_history.media_type = "track" ' \ ' GROUP BY %s) AS t ' \ 'GROUP BY t.grandparent_title ' \ 'ORDER BY users_watched DESC, %s DESC ' \ - 'LIMIT %s ' % (time_range, library_cond, group_by, sort_type, stats_count) + 'LIMIT %s ' % (time_range, group_by, sort_type, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: popular_music: %s." % e) @@ -439,6 +453,9 @@ class DataFactory(object): 'grandparent_thumb': item['grandparent_thumb'], 'thumb': '', 'section_id': item['section_id'], + 'media_type': item['media_type'], + 'content_rating': item['content_rating'], + 'labels': item['labels'].split(';') if item['labels'] else (), 'user': '', 'friendly_name': '', 'platform_type': '', @@ -501,7 +518,7 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': session.mask_session_info(top_users)}) + 'rows': session.mask_session_info(top_users, mask_metadata=False)}) elif stat == 'top_platforms': top_platform = [] @@ -547,13 +564,14 @@ class DataFactory(object): home_stats.append({'stat_id': stat, 'stat_type': sort_type, - 'rows': session.mask_session_info(top_platform)}) + 'rows': session.mask_session_info(top_platform, mask_metadata=False)}) elif stat == 'last_watched': last_watched = [] try: query = 'SELECT t.id, t.full_title, t.rating_key, t.thumb, t.grandparent_thumb, ' \ 't.user, t.user_id, t.custom_avatar_url as user_thumb, t.player, t.section_id, ' \ + 't.media_type, t.content_rating, t.labels, ' \ '(CASE WHEN t.friendly_name IS NULL THEN t.username ELSE t.friendly_name END) ' \ ' AS friendly_name, ' \ 'MAX(t.started) AS last_watch, ' \ @@ -566,12 +584,12 @@ class DataFactory(object): ' WHERE datetime(session_history.stopped, "unixepoch", "localtime") ' \ ' >= datetime("now", "-%s days", "localtime") ' \ ' AND (session_history.media_type = "movie" ' \ - ' OR session_history_metadata.media_type = "episode") %s' \ + ' OR session_history_metadata.media_type = "episode") ' \ ' GROUP BY %s) AS t ' \ 'WHERE percent_complete >= %s ' \ 'GROUP BY t.id ' \ 'ORDER BY last_watch DESC ' \ - 'LIMIT %s' % (time_range, library_cond, group_by, notify_watched_percent, stats_count) + 'LIMIT %s' % (time_range, group_by, notify_watched_percent, stats_count) result = monitor_db.select(query) except Exception as e: logger.warn(u"PlexPy DataFactory :: Unable to execute database query for get_home_stats: last_watched: %s." % e) @@ -593,6 +611,9 @@ class DataFactory(object): 'thumb': thumb, 'grandparent_thumb': item['grandparent_thumb'], 'section_id': item['section_id'], + 'media_type': item['media_type'], + 'content_rating': item['content_rating'], + 'labels': item['labels'].split(';') if item['labels'] else (), 'last_watch': item['last_watch'], 'player': item['player'] } @@ -686,6 +707,9 @@ class DataFactory(object): def get_library_stats(self, library_cards=[]): monitor_db = database.MonitorDatabase() + if session.get_session_shared_libraries(): + library_cards = session.get_session_shared_libraries() + library_stats = [] for id in library_cards: @@ -718,7 +742,7 @@ class DataFactory(object): } library_stats.append(library) - return session.filter_session_info(library_stats, filter_key='section_id') + return library_stats def get_stream_details(self, row_id=None): monitor_db = database.MonitorDatabase() diff --git a/plexpy/datatables.py b/plexpy/datatables.py index 88275b3c..293c3b59 100644 --- a/plexpy/datatables.py +++ b/plexpy/datatables.py @@ -92,12 +92,18 @@ class DataTables(object): if isinstance(w[1], (list, tuple)) and len(w[1]): c_where += '(' for w_ in w[1]: - c_where += w[0] + ' = ? OR ' - args.append(w_) + if w_ == None: + c_where += w[0] + ' IS NULL OR ' + else: + c_where += w[0] + ' = ? OR ' + args.append(w_) c_where = c_where.rstrip(' OR ') + ') AND ' else: - c_where += w[0] + ' = ? AND ' - args.append(w[1]) + if w[1] == None: + c_where += w[0] + ' IS NULL AND ' + else: + c_where += w[0] + ' = ? AND ' + args.append(w[1]) if c_where: c_where = 'WHERE ' + c_where.rstrip(' AND ') diff --git a/plexpy/libraries.py b/plexpy/libraries.py index 24d9bd7a..f7228c92 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -182,8 +182,8 @@ class Libraries(object): custom_where = [['library_sections.deleted_section', 0]] - if session.get_session_libraries(): - custom_where.append(['library_sections.section_id', session.get_session_libraries()]) + if session.get_session_shared_libraries(): + custom_where.append(['library_sections.section_id', session.get_session_shared_libraries()]) columns = ['library_sections.section_id', 'library_sections.section_name', @@ -210,6 +210,8 @@ class Libraries(object): 'session_history_metadata.year', 'session_history_metadata.media_index', 'session_history_metadata.parent_media_index', + 'session_history_metadata.content_rating', + 'session_history_metadata.labels', 'library_sections.do_notify', 'library_sections.do_notify_created', 'library_sections.keep_history' @@ -271,6 +273,8 @@ class Libraries(object): 'year': item['year'], 'media_index': item['media_index'], 'parent_media_index': item['parent_media_index'], + 'content_rating': item['content_rating'], + 'labels': item['labels'].split(';') if item['labels'] else (), 'do_notify': helpers.checked(item['do_notify']), 'do_notify_created': helpers.checked(item['do_notify_created']), 'keep_history': helpers.checked(item['keep_history']) @@ -280,7 +284,7 @@ class Libraries(object): dict = {'recordsFiltered': query['filteredCount'], 'recordsTotal': query['totalCount'], - 'data': rows, + 'data': session.mask_session_info(rows), 'draw': query['draw'] } @@ -787,7 +791,7 @@ class Libraries(object): query = 'SELECT session_history.id, session_history.media_type, ' \ 'session_history.rating_key, session_history.parent_rating_key, session_history.grandparent_rating_key, ' \ 'title, parent_title, grandparent_title, thumb, parent_thumb, grandparent_thumb, media_index, parent_media_index, ' \ - 'year, started, user ' \ + 'year, started, user, content_rating, labels, section_id ' \ 'FROM session_history_metadata ' \ 'JOIN session_history ON session_history_metadata.id = session_history.id ' \ 'WHERE section_id = ? ' \ @@ -822,11 +826,14 @@ class Libraries(object): 'parent_media_index': row['parent_media_index'], 'year': row['year'], 'time': row['started'], - 'user': row['user'] + 'user': row['user'], + 'section_id': row['section_id'], + 'content_rating': row['content_rating'], + 'labels': row['labels'].split(';') if row['labels'] else (), } recently_watched.append(recent_output) - return recently_watched + return session.mask_session_info(recently_watched) def get_sections(self): monitor_db = database.MonitorDatabase() diff --git a/plexpy/plextv.py b/plexpy/plextv.py index 4a122baf..74e21881 100644 --- a/plexpy/plextv.py +++ b/plexpy/plextv.py @@ -131,9 +131,7 @@ class PlexTV(object): self.password = password self.ssl_verify = plexpy.CONFIG.VERIFY_SSL_CERT - if token == 'admin': - token = plexpy.CONFIG.PMS_TOKEN - elif not token: + if not token: # Check if we should use the admin token, or the guest server token if session.get_session_user_id(): user_data = users.Users() diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py index f0ac4b66..cbe2118b 100644 --- a/plexpy/pmsconnect.py +++ b/plexpy/pmsconnect.py @@ -112,9 +112,7 @@ class PmsConnect(object): port = plexpy.CONFIG.PMS_PORT self.protocol = 'http' - if token == 'admin': - token = plexpy.CONFIG.PMS_TOKEN - elif not token: + if not token: # Check if we should use the admin token, or the guest server token if session.get_session_user_id(): user_data = users.Users() @@ -1077,6 +1075,11 @@ class PmsConnect(object): else: machine_id = helpers.get_xml_attr(session.getElementsByTagName('Player')[0], 'machineIdentifier') + labels = [] + if session.getElementsByTagName('Label'): + for label in session.getElementsByTagName('Label'): + labels.append(helpers.get_xml_attr(label, 'tag')) + session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'), 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'media_index': helpers.get_xml_attr(session, 'index'), @@ -1102,6 +1105,8 @@ class PmsConnect(object): 'rating_key': helpers.get_xml_attr(session, 'ratingKey'), 'parent_rating_key': helpers.get_xml_attr(session, 'parentRatingKey'), 'grandparent_rating_key': helpers.get_xml_attr(session, 'grandparentRatingKey'), + 'content_rating': helpers.get_xml_attr(session, 'contentRating'), + 'labels': labels, 'transcode_key': transcode_key, 'throttled': throttled, 'transcode_progress': int(round(helpers.cast_to_float(transcode_progress), 0)), @@ -1177,7 +1182,6 @@ class PmsConnect(object): transcode_container = '' transcode_protocol = '' - media_info = session.getElementsByTagName('Media')[0] if media_info.getElementsByTagName('Part'): indexes = helpers.get_xml_attr(media_info.getElementsByTagName('Part')[0], 'indexes') part_id = helpers.get_xml_attr(media_info.getElementsByTagName('Part')[0], 'id') @@ -1202,6 +1206,11 @@ class PmsConnect(object): else: machine_id = helpers.get_xml_attr(session.getElementsByTagName('Player')[0], 'machineIdentifier') + labels = [] + if session.getElementsByTagName('Label'): + for label in session.getElementsByTagName('Label'): + labels.append(helpers.get_xml_attr(label, 'tag')) + if helpers.get_xml_attr(session, 'type') == 'episode': session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'), 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), @@ -1228,6 +1237,8 @@ class PmsConnect(object): 'rating_key': helpers.get_xml_attr(session, 'ratingKey'), 'parent_rating_key': helpers.get_xml_attr(session, 'parentRatingKey'), 'grandparent_rating_key': helpers.get_xml_attr(session, 'grandparentRatingKey'), + 'content_rating': helpers.get_xml_attr(session, 'contentRating'), + 'labels': labels, 'transcode_key': transcode_key, 'throttled': throttled, 'transcode_progress': int(round(helpers.cast_to_float(transcode_progress), 0)), @@ -1287,6 +1298,8 @@ class PmsConnect(object): 'rating_key': helpers.get_xml_attr(session, 'ratingKey'), 'parent_rating_key': helpers.get_xml_attr(session, 'parentRatingKey'), 'grandparent_rating_key': helpers.get_xml_attr(session, 'grandparentRatingKey'), + 'content_rating': helpers.get_xml_attr(session, 'contentRating'), + 'labels': labels, 'transcode_key': transcode_key, 'throttled': throttled, 'transcode_progress': int(round(helpers.cast_to_float(transcode_progress), 0)), @@ -1346,6 +1359,8 @@ class PmsConnect(object): 'rating_key': helpers.get_xml_attr(session, 'ratingKey'), 'parent_rating_key': helpers.get_xml_attr(session, 'parentRatingKey'), 'grandparent_rating_key': helpers.get_xml_attr(session, 'grandparentRatingKey'), + 'content_rating': helpers.get_xml_attr(session, 'contentRating'), + 'labels': labels, 'transcode_key': transcode_key, 'throttled': throttled, 'transcode_progress': int(round(helpers.cast_to_float(transcode_progress), 0)), @@ -1415,6 +1430,11 @@ class PmsConnect(object): else: machine_id = helpers.get_xml_attr(session.getElementsByTagName('Player')[0], 'machineIdentifier') + labels = [] + if session.getElementsByTagName('Label'): + for label in session.getElementsByTagName('Label'): + labels.append(helpers.get_xml_attr(label, 'tag')) + session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'), 'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'media_index': helpers.get_xml_attr(session, 'index'), @@ -1440,6 +1460,8 @@ class PmsConnect(object): 'rating_key': helpers.get_xml_attr(session, 'ratingKey'), 'parent_rating_key': helpers.get_xml_attr(session, 'parentRatingKey'), 'grandparent_rating_key': helpers.get_xml_attr(session, 'grandparentRatingKey'), + 'content_rating': helpers.get_xml_attr(session, 'contentRating'), + 'labels': labels, 'transcode_key': transcode_key, 'throttled': throttled, 'transcode_progress': int(round(helpers.cast_to_float(transcode_progress), 0)), diff --git a/plexpy/session.py b/plexpy/session.py index f9047ac2..cd72257c 100644 --- a/plexpy/session.py +++ b/plexpy/session.py @@ -14,7 +14,6 @@ # along with PlexPy. If not, see . import cherrypy -import threading from plexpy import common @@ -28,7 +27,6 @@ def get_session_info(): _session = {'user_id': None, 'user': None, 'user_group': 'admin', - 'user_libraries': None, 'expiry': None} try: return cherrypy.session.get(SESSION_KEY, _session) @@ -49,12 +47,49 @@ def get_session_user_id(): _session = get_session_info() return str(_session['user_id']) if _session and _session['user_id'] else None -def get_session_libraries(): +def get_session_shared_libraries(): """ Returns a tuple of section_id for the current logged in session """ - _session = get_session_info() - return _session['user_libraries'] if _session and _session['user_libraries'] else None + from plexpy import users + user_details = users.Users().get_details(user_id=get_session_user_id()) + return user_details['shared_libraries'] + +def get_session_library_filters(): + """ + Returns a dict of library filters for the current logged in session + + {'content_rating': ('PG', 'R') + 'labels': ('label1', label2')}, + + """ + from plexpy import users + filters = users.Users().get_filters(user_id=get_session_user_id()) + return filters + +def get_session_library_filters_type(filters, media_type=None): + """ + Returns a dict of library filters for the current logged in session + + {'content_rating': ('PG', 'R') + 'labels': ('label1', label2')}, + + """ + if media_type == 'movie': + filters = filters.get('filter_movies', ()) + elif media_type == 'show' or media_type == 'season' or media_type == 'episode': + filters = filters.get('filter_tv', ()) + elif media_type == 'artist' or media_type == 'album' or media_type == 'track': + filters = filters.get('filter_music', ()) + elif media_type == 'photo' or media_type == 'photoAlbum' or media_type == 'picture': + filters = filters.get('filter_photos', ()) + else: + filters = filters.get('filter_all', ()) + + content_rating = filters.get('content_rating', ()) + labels = filters.get('labels', ()) + + return content_rating, tuple(f.lower() for f in labels) def allow_session_user(user_id): """ @@ -69,74 +104,11 @@ def allow_session_library(section_id): """ Returns True or False if the section_id is allowed for the current logged in session """ - session_library_ids = get_session_libraries() + session_library_ids = get_session_shared_libraries() if session_library_ids and str(section_id) not in session_library_ids: return False return True -def filter_session_info(list_of_dicts, filter_key=None): - """ - Filters a list of dictionary items to only return the info for the current logged in session - """ - session_user_id = get_session_user_id() - session_library_ids = get_session_libraries() - - list_of_dicts = friendly_name_to_username(list_of_dicts) - - if filter_key == 'user_id' and session_user_id: - return [d for d in list_of_dicts if str(d.get('user_id','')) == session_user_id] - - elif filter_key == 'section_id' and session_library_ids: - return [d for d in list_of_dicts if str(d.get('section_id','')) in session_library_ids] - - return list_of_dicts - -def mask_session_info(list_of_dicts, mask_metadata=True): - """ - Masks user info in a list of dictionary items to only display info for the current logged in session - """ - session_user = get_session_user() - session_user_id = get_session_user_id() - session_library_ids = get_session_libraries() - - keys_to_mask = {'user_id': '', - 'user': 'Plex User', - 'friendly_name': 'Plex User', - 'user_thumb': common.DEFAULT_USER_THUMB, - 'ip_address': 'N/A', - 'machine_id': '', - 'player': 'Player' - } - - metadata_to_mask = {'media_index': '', - 'parent_media_index': '', - 'art': common.DEFAULT_ART, - 'parent_thumb': common.DEFAULT_POSTER_THUMB, - 'grandparent_thumb': common.DEFAULT_POSTER_THUMB, - 'thumb': common.DEFAULT_POSTER_THUMB, - 'bif_thumb': '', - 'grandparent_title': '', - 'parent_title': '', - 'title': '', - 'rating_key': '', - 'parent_rating_key': '', - 'grandparent_rating_key': '', - 'year': '' - } - - list_of_dicts = friendly_name_to_username(list_of_dicts) - - for d in list_of_dicts: - if session_user_id and not (str(d.get('user_id')) == session_user_id or d.get('user') == session_user): - for k, v in keys_to_mask.iteritems(): - if k in d: d[k] = keys_to_mask[k] - - if mask_metadata and session_library_ids and str(d.get('section_id','')) not in session_library_ids: - for k, v in metadata_to_mask.iteritems(): - if k in d: d[k] = metadata_to_mask[k] - - return list_of_dicts - def friendly_name_to_username(list_of_dicts): """ Reverts the friendly name back to the username of the current logged in session @@ -149,4 +121,125 @@ def friendly_name_to_username(list_of_dicts): if 'friendly_name' in d and d['friendly_name'] != session_user: d['friendly_name'] = session_user + return list_of_dicts + +def filter_session_info(list_of_dicts, filter_key=None): + """ + Filters a list of dictionary items to only return the info for the current logged in session + """ + session_user_id = get_session_user_id() + + if not session_user_id: + return list_of_dicts + + session_library_ids = get_session_shared_libraries() + session_library_filters = get_session_library_filters() + + list_of_dicts = friendly_name_to_username(list_of_dicts) + + if filter_key == 'user_id' and session_user_id: + return [d for d in list_of_dicts if str(d.get('user_id','')) == session_user_id] + + elif filter_key == 'section_id' and session_library_ids: + new_list_of_dicts = [] + + for d in list_of_dicts: + if str(d.get('section_id','')) not in session_library_ids: + continue + + if d.get('media_type'): + f_content_rating, f_labels = get_session_library_filters_type(session_library_filters, + media_type=d['media_type']) + + d_content_rating = d.get('content_rating', '').lower() + d_labels = tuple(f.lower() for f in d.get('labels', ())) + + if (not f_content_rating or set(d_content_rating).intersection(set(f_content_rating))) and \ + (not f_labels or set(d_labels).intersection(set(f_labels))): + continue + + new_list_of_dicts.append(d) + + return new_list_of_dicts + + return list_of_dicts + +def mask_session_info(list_of_dicts, mask_metadata=True): + """ + Masks user info in a list of dictionary items to only display info for the current logged in session + """ + session_user_id = get_session_user_id() + + if not session_user_id: + return list_of_dicts + + session_user = get_session_user() + session_library_ids = get_session_shared_libraries() + session_library_filters = get_session_library_filters() + + keys_to_mask = {'user_id': '', + 'user': 'Plex User', + 'friendly_name': 'Plex User', + 'user_thumb': common.DEFAULT_USER_THUMB, + 'ip_address': 'N/A', + 'machine_id': '', + 'player': 'Player' + } + + metadata_to_mask = {'media_index': '0', + 'parent_media_index': '0', + 'art': common.DEFAULT_ART, + 'parent_thumb': common.DEFAULT_POSTER_THUMB, + 'grandparent_thumb': common.DEFAULT_POSTER_THUMB, + 'thumb': common.DEFAULT_POSTER_THUMB, + 'bif_thumb': '', + 'grandparent_title': 'Title', + 'parent_title': 'Title', + 'title': 'Title', + 'rating_key': '', + 'parent_rating_key': '', + 'grandparent_rating_key': '', + 'year': '', + 'last_played': 'Title' + } + + list_of_dicts = friendly_name_to_username(list_of_dicts) + + for d in list_of_dicts: + if session_user_id and not (str(d.get('user_id')) == session_user_id or d.get('user') == session_user): + for k, v in keys_to_mask.iteritems(): + if k in d: d[k] = keys_to_mask[k] + + if not mask_metadata: + continue + + if str(d.get('section_id','')) not in session_library_ids: + for k, v in metadata_to_mask.iteritems(): + if k in d: d[k] = metadata_to_mask[k] + continue + + media_type = d.get('media_type') + if media_type: + f_content_rating, f_labels = get_session_library_filters_type(session_library_filters, + media_type=d['media_type']) + + if not f_content_rating and not f_labels: + continue + + d_content_rating = d.get('content_rating', '') + d_labels = tuple(f.lower() for f in d.get('labels', ())) + + if not f_content_rating and f_labels: + if set(d_labels).intersection(set(f_labels)): + continue + elif f_content_rating and not f_labels: + if d_content_rating in f_content_rating: + continue + elif f_content_rating and f_labels: + if d_content_rating in f_content_rating or set(d_labels).intersection(set(f_labels)): + continue + + for k, v in metadata_to_mask.iteritems(): + if k in d: d[k] = metadata_to_mask[k] + return list_of_dicts \ No newline at end of file diff --git a/plexpy/users.py b/plexpy/users.py index 1b89e8c9..0add1508 100644 --- a/plexpy/users.py +++ b/plexpy/users.py @@ -16,73 +16,6 @@ import plexpy from plexpy import logger, datatables, common, database, helpers, session -def user_login(username=None, password=None): - from plexpy import plextv - - if not username and not password: - return None - - user_data = Users() - - # Try to login to Plex.tv to check if the user has a vaild account - plex_tv = plextv.PlexTV(username=username, password=password) - plex_user = plex_tv.get_token() - if plex_user: - user_token = plex_user['auth_token'] - user_id = plex_user['user_id'] - - # Retrieve user token from the database and check against the Plex.tv token. - # Also Make sure 'allow_guest' access is enabled for the user. - # The user tokens should match if it is the same PlexPy install. - tokens = user_data.get_tokens(user_id=user_id) - if not tokens: - # The user is not in the database - return None - elif not tokens['allow_guest'] or not user_token == tokens['user_token']: - # Guest access is disabled, or user tokens don't match - return None - - # Otherwise it is a new user or token is no longer valid. - # Check if the user is in the database, not deleted, and 'allow_guest' access. - user_details = user_data.get_details(user_id=user_id) - if user_id == str(user_details['user_id']) and \ - not user_details['deleted_user'] and user_details['allow_guest']: - - # The user is in the database, so try to retrieve a new server token. - # If a server token is returned, then the user is a valid friend - plex_tv = plextv.PlexTV(token=user_token) - server_token = plex_tv.get_server_token() - if server_token: - - # Register the new user / update the access tokens. - monitor_db = database.MonitorDatabase() - try: - logger.debug(u"PlexPy Users :: Regestering tokens for user '%s' in the database." % username) - result = monitor_db.action('UPDATE users SET user_token = ?, server_token = ? WHERE user_id = ?', - [user_token, server_token, user_id]) - - if result: - # Successful login - return True - else: - logger.warn(u"PlexPy Users :: Unable to register user '%s' in database." % username) - return None - except Exception as e: - logger.warn(u"PlexPy Users :: Unable to register user '%s' in database: %s." % (username, e)) - return None - else: - logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv server token for user '%s'." % username) - return None - else: - logger.warn(u"PlexPy Users :: Unable to register user '%s'. User not in the database." % username) - return None - else: - logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv user token for user '%s'." % username) - return None - - return None - - class Users(object): def __init__(self): @@ -333,7 +266,8 @@ class Users(object): 'is_restricted': 0, 'do_notify': 0, 'keep_history': 1, - 'allow_guest': 0 + 'allow_guest': 0, + 'shared_libraries': () } if not user_id and not user and not email: @@ -345,19 +279,22 @@ class Users(object): try: if str(user_id).isdigit(): query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, allow_guest ' \ + 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \ + 'allow_guest, shared_libraries ' \ 'FROM users ' \ 'WHERE user_id = ? ' result = monitor_db.select(query, args=[user_id]) elif user: query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, allow_guest ' \ + 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \ + 'allow_guest, shared_libraries ' \ 'FROM users ' \ 'WHERE username = ? ' result = monitor_db.select(query, args=[user]) elif email: query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ - 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, allow_guest ' \ + 'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \ + 'allow_guest, shared_libraries ' \ 'FROM users ' \ 'WHERE email = ? ' result = monitor_db.select(query, args=[email]) @@ -395,7 +332,8 @@ class Users(object): 'do_notify': item['do_notify'], 'keep_history': item['keep_history'], 'deleted_user': item['deleted_user'], - 'allow_guest': item['allow_guest'] + 'allow_guest': item['allow_guest'], + 'shared_libraries': tuple(item['shared_libraries'].split(';')) } return user_details @@ -691,3 +629,33 @@ class Users(object): return None return None + + def get_filters(self, user_id=None): + import urlparse + + if not user_id: + return {} + + try: + monitor_db = database.MonitorDatabase() + query = 'SELECT filter_all, filter_movies, filter_tv, filter_music, filter_photos FROM users ' \ + 'WHERE user_id = ?' + result = monitor_db.select_single(query, args=[user_id]) + except Exception as e: + logger.warn(u"PlexPy Users :: Unable to execute database query for get_filters: %s." % e) + result = {} + + filters_list = {} + for k, v in result.iteritems(): + filters = {} + + for f in v.split('|'): + if 'contentRating=' in f or 'label=' in f: + filters.update(dict(urlparse.parse_qsl(f))) + + filters['content_rating'] = tuple(f for f in filters.pop('contentRating', '').split(',') if f) + filters['labels'] = tuple(f for f in filters.pop('label', '').split(',') if f) + + filters_list[k] = filters + + return filters_list \ No newline at end of file diff --git a/plexpy/webauth.py b/plexpy/webauth.py index 878e79a1..394ee2ba 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -26,12 +26,78 @@ import re import plexpy from plexpy import logger -from plexpy.users import Users, user_login +from plexpy.database import MonitorDatabase +from plexpy.users import Users +from plexpy.plextv import PlexTV from plexpy.pmsconnect import PmsConnect SESSION_KEY = '_cp_username' +def user_login(username=None, password=None): + if not username and not password: + return None + + user_data = Users() + + # Try to login to Plex.tv to check if the user has a vaild account + plex_tv = PlexTV(username=username, password=password) + plex_user = plex_tv.get_token() + if plex_user: + user_token = plex_user['auth_token'] + user_id = plex_user['user_id'] + + # Retrieve user token from the database and check against the Plex.tv token. + # Also Make sure 'allow_guest' access is enabled for the user. + # The user tokens should match if it is the same PlexPy install. + tokens = user_data.get_tokens(user_id=user_id) + if not tokens: + # The user is not in the database + return None + elif not tokens['allow_guest'] or not user_token == tokens['user_token']: + # Guest access is disabled, or user tokens don't match + return None + + # Otherwise it is a new user or token is no longer valid. + # Check if the user is in the database, not deleted, and 'allow_guest' access. + user_details = user_data.get_details(user_id=user_id) + if user_id == str(user_details['user_id']) and \ + not user_details['deleted_user'] and user_details['allow_guest']: + + # The user is in the database, so try to retrieve a new server token. + # If a server token is returned, then the user is a valid friend + plex_tv = PlexTV(token=user_token) + server_token = plex_tv.get_server_token() + if server_token: + + # Register the new user / update the access tokens. + monitor_db = MonitorDatabase() + try: + logger.debug(u"PlexPy Users :: Regestering tokens for user '%s' in the database." % username) + result = monitor_db.action('UPDATE users SET user_token = ?, server_token = ? WHERE user_id = ?', + [user_token, server_token, user_id]) + + if result: + # Successful login + return True + else: + logger.warn(u"PlexPy Users :: Unable to register user '%s' in database." % username) + return None + except Exception as e: + logger.warn(u"PlexPy Users :: Unable to register user '%s' in database: %s." % (username, e)) + return None + else: + logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv server token for user '%s'." % username) + return None + else: + logger.warn(u"PlexPy Users :: Unable to register user '%s'. User not in the database." % username) + return None + else: + logger.warn(u"PlexPy Users :: Unable to retrieve Plex.tv user token for user '%s'." % username) + return None + + return None + def check_credentials(username, password): """Verifies credentials for username and password. Returns True and the user group on success or False and no user group""" @@ -151,16 +217,10 @@ class AuthController(object): user_details = Users().get_details(email=username) else: user_details = Users().get_details(user=username) + user_id = user_details['user_id'] - - user_tokens = Users().get_tokens(user_id=user_details['user_id']) - server_token = user_tokens['server_token'] - - library_details = PmsConnect(token=server_token).get_server_children() - user_libraries = tuple(d['section_id'] for d in library_details['libraries_list']) else: user_id = None - user_libraries = None expiry = datetime.now() + (timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)) @@ -169,7 +229,6 @@ class AuthController(object): cherrypy.session[SESSION_KEY] = {'user_id': user_id, 'user': username, 'user_group': user_group, - 'user_libraries': user_libraries, 'expiry': expiry} self.on_login(username) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index e5cde339..11a7c57b 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -29,7 +29,7 @@ from plexpy import logger, notifiers, plextv, pmsconnect, common, log_reader, \ datafactory, graphs, users, libraries, database, web_socket from plexpy.api2 import API2 from plexpy.helpers import checked, addtoapi, get_ip, create_https_certificates -from plexpy.session import get_session_info, allow_session_user, allow_session_library +from plexpy.session import get_session_info, get_session_user_id, allow_session_user, allow_session_library from plexpy.webauth import AuthController, requireAuth, member_of, name_is, SESSION_KEY @@ -182,7 +182,7 @@ class WebInterface(object): def get_current_activity(self, **kwargs): try: - pms_connect = pmsconnect.PmsConnect() + pms_connect = pmsconnect.PmsConnect(token=plexpy.CONFIG.PMS_TOKEN) result = pms_connect.get_current_activity() data_factory = datafactory.DataFactory() @@ -205,7 +205,7 @@ class WebInterface(object): def get_current_activity_header(self, **kwargs): try: - pms_connect = pmsconnect.PmsConnect() + pms_connect = pmsconnect.PmsConnect(token=plexpy.CONFIG.PMS_TOKEN) result = pms_connect.get_current_activity() except: return serve_template(templatename="current_activity_header.html", data=None) @@ -468,7 +468,7 @@ class WebInterface(object): return serve_template(templatename="library_recently_added.html", data=None, title="Recently Added") @cherrypy.expose - @requireAuth() + @requireAuth(member_of("admin")) @addtoapi() def get_library_media_info(self, section_id=None, section_type=None, rating_key=None, refresh='', **kwargs): @@ -1864,7 +1864,10 @@ class WebInterface(object): return serve_template(templatename="info.html", data=metadata, title="Info", config=config, source=source) else: - return self.update_metadata(rating_key, query) + if get_session_user_id(): + raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + else: + return self.update_metadata(rating_key, query) @cherrypy.expose @requireAuth() From 1622b0fa29dd83d197fda3ea626617493d4bc467 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sun, 1 May 2016 03:21:10 -0700 Subject: [PATCH 049/132] Make sure info pages are protected if source=history --- data/interfaces/default/current_activity.html | 6 ++-- plexpy/datafactory.py | 18 +++++++++--- plexpy/session.py | 29 ++++++++++++------- plexpy/users.py | 4 ++- plexpy/webserve.py | 8 +++-- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/data/interfaces/default/current_activity.html b/data/interfaces/default/current_activity.html index 1caa1d3b..02901de7 100644 --- a/data/interfaces/default/current_activity.html +++ b/data/interfaces/default/current_activity.html @@ -245,7 +245,7 @@ DOCUMENTATION :: END % elif a['state'] == 'buffering':   % endif - % if a['title']: + % if a['rating_key']: % if a['media_type'] == 'episode': ${a['grandparent_title']} - ${a['title']} % elif a['media_type'] == 'movie': @@ -260,11 +260,11 @@ DOCUMENTATION :: END ${a['title']} % endif % else: - Title + Title % endif
    +% if _session['user_group'] != 'admin': + +% endif + ${next.headerIncludes()}
    ${next.body()} @@ -322,6 +364,12 @@ ${next.headerIncludes()} }); } }); + + % if _session['user_group'] != 'admin': + $('#admin-login-modal').on('shown.bs.modal', function () { + $('#admin-login-modal #username').focus() + }) + % endif ${next.javascriptIncludes()} diff --git a/data/interfaces/default/css/plexpy.css b/data/interfaces/default/css/plexpy.css index 5f40abeb..c9d6477f 100644 --- a/data/interfaces/default/css/plexpy.css +++ b/data/interfaces/default/css/plexpy.css @@ -2908,4 +2908,18 @@ a.no-highlight:hover { margin-bottom: 0; font-weight: 400; cursor: pointer; +} +#admin-login-modal .form-group label { + font-weight: 400; + color: #999; +} +#admin-login-modal .remember-group { + float: left; + color: #999; +} +#admin-login-modal .remember-group .control-label { + display: inline; + margin-bottom: 0; + font-weight: 400; + cursor: pointer; } \ No newline at end of file diff --git a/data/interfaces/default/login.html b/data/interfaces/default/login.html index 5c1846b5..a535f0e4 100644 --- a/data/interfaces/default/login.html +++ b/data/interfaces/default/login.html @@ -31,13 +31,13 @@ ${msg}
    % endif -
    +
    -
    +
    diff --git a/plexpy/webauth.py b/plexpy/webauth.py index 394ee2ba..7c0a7221 100644 --- a/plexpy/webauth.py +++ b/plexpy/webauth.py @@ -98,7 +98,7 @@ def user_login(username=None, password=None): return None -def check_credentials(username, password): +def check_credentials(username, password, admin_login='0'): """Verifies credentials for username and password. Returns True and the user group on success or False and no user group""" @@ -107,7 +107,7 @@ def check_credentials(username, password): return True, u'admin' elif username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: return True, u'admin' - elif plexpy.CONFIG.ALLOW_GUEST_ACCESS and user_login(username, password): + elif not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS and user_login(username, password): return True, u'guest' else: return False, None @@ -202,14 +202,14 @@ class AuthController(object): raise cherrypy.HTTPRedirect("login") @cherrypy.expose - def login(self, username=None, password=None, remember_me='0'): + def login(self, username=None, password=None, remember_me='0', admin_login='0'): if not cherrypy.config.get('tools.sessions.on'): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) if username is None or password is None: return self.get_loginform() - (vaild_login, user_group) = check_credentials(username, password) + (vaild_login, user_group) = check_credentials(username, password, admin_login) if vaild_login: if user_group == 'guest': @@ -234,6 +234,9 @@ class AuthController(object): self.on_login(username) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) + elif admin_login == '1': + logger.debug(u"Invalid admin login attempt from '%s'." % username) + raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) else: logger.debug(u"Invalid login attempt from '%s'." % username) return self.get_loginform(username, u"Incorrect username/email or password.") From 0781018e4ef28c503be693f72f6f9a5067f2c0d9 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Sun, 1 May 2016 14:23:25 -0700 Subject: [PATCH 051/132] Rename masked Title to Plex Media --- data/interfaces/default/current_activity.html | 2 +- plexpy/session.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/data/interfaces/default/current_activity.html b/data/interfaces/default/current_activity.html index 02901de7..ad80b304 100644 --- a/data/interfaces/default/current_activity.html +++ b/data/interfaces/default/current_activity.html @@ -260,7 +260,7 @@ DOCUMENTATION :: END ${a['title']} % endif % else: - Title + ${a['title']} % endif
    + % if _session['user_group'] == 'admin':
     
    - % if _session['user_group'] == 'admin': % if config['update_section_ids'] == -1:
    @@ -213,4 +213,4 @@ }); }); - \ No newline at end of file + From 6fe115fd0d55ef58c9e0f82e132ba81b5fb864e9 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Mon, 2 May 2016 08:37:13 -0700 Subject: [PATCH 056/132] Hide update notification from guests --- data/interfaces/default/base.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index 59f98e79..e0b68115 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -135,6 +135,7 @@ from plexpy.helpers import anon_url
    + % if _session['user_group'] == 'admin': % if plexpy.CONFIG.CHECK_GITHUB and not plexpy.CURRENT_VERSION: % endif + % endif
    @@ -108,7 +107,6 @@ $('#dashboard-checking-activity').remove(); var current_activity = $.parseJSON(xhr.responseText); - console.log(current_activity) var stream_count = parseInt(current_activity.stream_count); var sessions = current_activity.sessions; @@ -147,7 +145,7 @@ $('#play-state-' + key).html(state_icon); // if using bif indexes, update the bif thumbnail - if (s.indexes == 1) { + if (s.indexes) { var bif_poster = $('#bif-' + key); bif_poster.animate({ opacity: 0 }, { duration: 1000, queue: false }); bif_poster.after($('
    Nothing is currently being played.
    '); + $('#currentActivity').html('
    Nothing is currently being played.
    '); } } }); @@ -216,6 +214,10 @@ $('#currentActivity').on('click', '.btn-activity-info', function (e) { e.preventDefault(); $($(this).attr('data-target')).toggle(); + var id = $(this).closest('.dashboard-instance').data('id'); + var filterVal = $('#stream-' + id).is(':visible') ? 'blur(5px)' : ''; + $($(this).closest('.dashboard-activity-poster').find('.dashboard-activity-poster-face, .dashboard-activity-cover-face')) + .css('filter',filterVal).css('webkitFilter',filterVal).css('mozFilter',filterVal).css('oFilter',filterVal).css('msFilter',filterVal); }); // Add hover class to dashboard-instance From cd35fa180226eee04baf603fed638586533b5984 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Wed, 11 May 2016 20:59:29 -0700 Subject: [PATCH 093/132] Fix current activity header for tracks --- plexpy/webserve.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/plexpy/webserve.py b/plexpy/webserve.py index f0b3730e..a47dda7d 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -248,16 +248,25 @@ class WebInterface(object): return serve_template(templatename="current_activity_header.html", data=None) if result: - data = {'stream_count': result['stream_count']} - data['direct_play'] = \ - sum(1 for s in result['sessions'] - if s['video_decision'] == 'direct play' and s['audio_decision'] == 'direct play') - data['direct_stream'] = \ - sum(1 for s in result['sessions'] - if s['video_decision'] == 'copy' and s['audio_decision'] == 'copy') - data['transcode'] = \ - sum(1 for s in result['sessions'] - if s['video_decision'] == 'transcode' or s['audio_decision'] == 'transcode') + data = {'stream_count': result['stream_count'], + 'direct_play': 0, + 'direct_stream': 0, + 'transcode': 0} + for s in result['sessions']: + if s['media_type'] == 'track': + if s['audio_decision'] == 'transcode': + data['transcode'] += 1 + elif s['audio_decision'] == 'copy': + data['direct_stream'] += 1 + else: + data['direct_play'] += 1 + else: + if s['video_decision'] == 'transcode' or s['audio_decision'] == 'transcode': + data['transcode'] += 1 + elif s['video_decision'] == 'direct copy' or s['audio_decision'] == 'copy play': + data['direct_stream'] += 1 + else: + data['direct_play'] += 1 return serve_template(templatename="current_activity_header.html", data=data) else: From 89d298ea65136d13a7ea790fe72748d13dbdb64b Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Wed, 11 May 2016 21:03:09 -0700 Subject: [PATCH 094/132] Add Imgur client id note in settings --- data/interfaces/default/settings.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index e48c11e2..03a76092 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -870,7 +870,9 @@

    Enter your Imgur API client ID in order to upload posters. - You can register a new application here. + You can register a new application here.
    + Note: The shared Imgur client id will be removed in a future PlexPy update! + Please enter your own client id in to continue uploading posters!

    From fed38bd046f615c7746688503f94f41b5c86c852 Mon Sep 17 00:00:00 2001 From: JonnyWong16 Date: Wed, 11 May 2016 21:25:20 -0700 Subject: [PATCH 095/132] Try a new stream info modal layout --- data/interfaces/default/stream_data.html | 93 +++++++++++++----------- plexpy/datafactory.py | 3 +- 2 files changed, 53 insertions(+), 43 deletions(-) diff --git a/data/interfaces/default/stream_data.html b/data/interfaces/default/stream_data.html index c0cf86fd..be84948c 100644 --- a/data/interfaces/default/stream_data.html +++ b/data/interfaces/default/stream_data.html @@ -20,6 +20,7 @@ transcode_width Returns the value of the video width for any transco transcode_audio_dec Returns the audio transcode decision. Either 'transcode', 'copy' or 'direct play'. transcode_audio_codec Returns the name of the audio codec for any transcode session. transcode_audio_channels Returns the number of audio channels for any transcode session. +transcode_container Returns the type of container for any transcode session. container Returns the type of container for the original media. height Returns the value of the video height for the original media. bitrate Returns the value of the video bitrate for the original media. @@ -42,7 +43,7 @@ DOCUMENTATION :: END