diff --git a/README.md b/README.md index 4f062fa..a1f066c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ A basic account management system for [Jellyfin](https://github.com/jellyfin/jel * requests * itsdangerous * passlib -* configparser * pyOpenSSL * waitress * pytz diff --git a/jellyfin_accounts/__init__.py b/jellyfin_accounts/__init__.py index 6e6af33..c108169 100755 --- a/jellyfin_accounts/__init__.py +++ b/jellyfin_accounts/__init__.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -__version__ = "0.2" +__version__ = "0.2.1" import secrets import configparser @@ -13,192 +13,223 @@ import json from pathlib import Path from flask import Flask, g from jellyfin_accounts.data_store import JSONStorage + parser = argparse.ArgumentParser(description="jellyfin-accounts") -parser.add_argument("-c", "--config", - help="specifies path to configuration file.") -parser.add_argument("-d", "--data", - help=("specifies directory to store data in. " + - "defaults to ~/.jf-accounts.")) -parser.add_argument("--host", - help="address to host web ui on.") -parser.add_argument("-p", "--port", - help="port to host web ui on.") -parser.add_argument("-g", "--get_defaults", - help=("tool to grab a JF users " + - "policy (access, perms, etc.) and " + - "homescreen layout and " + - "output it as json to be used as a user template."), - action='store_true') +parser.add_argument("-c", "--config", help="specifies path to configuration file.") +parser.add_argument( + "-d", + "--data", + help=("specifies directory to store data in. " + "defaults to ~/.jf-accounts."), +) +parser.add_argument("--host", help="address to host web ui on.") +parser.add_argument("-p", "--port", help="port to host web ui on.") +parser.add_argument( + "-g", + "--get_defaults", + help=( + "tool to grab a JF users " + + "policy (access, perms, etc.) and " + + "homescreen layout and " + + "output it as json to be used as a user template." + ), + action="store_true", +) args, leftovers = parser.parse_known_args() if args.data is not None: data_dir = Path(args.data) else: - data_dir = Path.home() / '.jf-accounts' + data_dir = Path.home() / ".jf-accounts" -local_dir = (Path(__file__).parent / 'data').resolve() +local_dir = (Path(__file__).parent / "data").resolve() first_run = False -if data_dir.exists() is False or (data_dir / 'config.ini').exists() is False: +if data_dir.exists() is False or (data_dir / "config.ini").exists() is False: if not data_dir.exists(): Path.mkdir(data_dir) - print(f'Config dir not found, so created at {str(data_dir)}') + print(f"Config dir not found, so created at {str(data_dir)}") if args.config is None: - config_path = data_dir / 'config.ini' - shutil.copy(str(local_dir / 'config-default.ini'), - str(config_path)) + config_path = data_dir / "config.ini" + shutil.copy(str(local_dir / "config-default.ini"), str(config_path)) print("Setup through the web UI, or quit and edit the configuration manually.") first_run = True else: config_path = Path(args.config) - print(f'config.ini can be found at {str(config_path)}') + print(f"config.ini can be found at {str(config_path)}") else: - config_path = data_dir / 'config.ini' + config_path = data_dir / "config.ini" config = configparser.RawConfigParser() config.read(config_path) + def create_log(name): log = logging.getLogger(name) handler = logging.StreamHandler(sys.stdout) - if config.getboolean('ui', 'debug'): + if config.getboolean("ui", "debug"): log.setLevel(logging.DEBUG) handler.setLevel(logging.DEBUG) else: log.setLevel(logging.INFO) handler.setLevel(logging.INFO) - fmt = ' %(name)s - %(levelname)s - %(message)s' + fmt = " %(name)s - %(levelname)s - %(message)s" format = logging.Formatter(fmt) handler.setFormatter(format) log.addHandler(handler) log.propagate = False return log -log = create_log('main') -web_log = create_log('waitress') + +log = create_log("main") +web_log = create_log("waitress") if not first_run: - email_log = create_log('emails') - auth_log = create_log('auth') + email_log = create_log("emails") + auth_log = create_log("auth") if args.host is not None: - log.debug(f'Using specified host {args.host}') - config['ui']['host'] = args.host + log.debug(f"Using specified host {args.host}") + config["ui"]["host"] = args.host if args.port is not None: - log.debug(f'Using specified port {args.port}') - config['ui']['port'] = args.port + log.debug(f"Using specified port {args.port}") + config["ui"]["port"] = args.port -for key in config['files']: - if config['files'][key] == '': - if key != 'custom_css': - log.debug(f'Using default {key}') - config['files'][key] = str(data_dir / (key + '.json')) +for key in config["files"]: + if config["files"][key] == "": + if key != "custom_css": + log.debug(f"Using default {key}") + config["files"][key] = str(data_dir / (key + ".json")) -for key in ['user_configuration', 'user_displayprefs']: - if key not in config['files']: - log.debug(f'Using default {key}') - config['files'][key] = str(data_dir / (key + '.json')) +for key in ["user_configuration", "user_displayprefs"]: + if key not in config["files"]: + log.debug(f"Using default {key}") + config["files"][key] = str(data_dir / (key + ".json")) -with open(config['files']['invites'], 'r') as f: +with open(config["files"]["invites"], "r") as f: temp_invites = json.load(f) -if 'invites' in temp_invites: +if "invites" in temp_invites: new_invites = {} - log.info('Converting invites.json to new format, temporary.') - for el in temp_invites['invites']: - i = {'valid_till': el['valid_till']} - if 'email' in el: - i['email'] = el['email'] - new_invites[el['code']] = i - with open(config['files']['invites'], 'w') as f: + log.info("Converting invites.json to new format, temporary.") + for el in temp_invites["invites"]: + i = {"valid_till": el["valid_till"]} + if "email" in el: + i["email"] = el["email"] + new_invites[el["code"]] = i + with open(config["files"]["invites"], "w") as f: f.write(json.dumps(new_invites, indent=4, default=str)) -data_store = JSONStorage(config['files']['emails'], - config['files']['invites'], - config['files']['user_template'], - config['files']['user_displayprefs'], - config['files']['user_configuration']) +data_store = JSONStorage( + config["files"]["emails"], + config["files"]["invites"], + config["files"]["user_template"], + config["files"]["user_displayprefs"], + config["files"]["user_configuration"], +) def default_css(): css = {} - css['href'] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" - css['integrity'] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" - css['crossorigin'] = "anonymous" + css[ + "href" + ] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" + css[ + "integrity" + ] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" + css["crossorigin"] = "anonymous" return css css = {} css = default_css() -if 'custom_css' in config['files']: - if config['files']['custom_css'] != '': +if "custom_css" in config["files"]: + if config["files"]["custom_css"] != "": try: - shutil.copy(config['files']['custom_css'], - (local_dir / 'static' / 'bootstrap.css')) - log.debug('Loaded custom CSS') - css['href'] = '/bootstrap.css' - css['integrity'] = '' - css['crossorigin'] = '' + shutil.copy( + config["files"]["custom_css"], (local_dir / "static" / "bootstrap.css") + ) + log.debug("Loaded custom CSS") + css["href"] = "/bootstrap.css" + css["integrity"] = "" + css["crossorigin"] = "" except FileNotFoundError: - log.error(f'Custom CSS {config["files"]["custom_css"]} not found, using default.') + log.error( + f'Custom CSS {config["files"]["custom_css"]} not found, using default.' + ) -if ('email_html' not in config['password_resets'] or - config['password_resets']['email_html'] == ''): - log.debug('Using default password reset email HTML template') - config['password_resets']['email_html'] = str(local_dir / 'email.html') -if ('email_text' not in config['password_resets'] or - config['password_resets']['email_text'] == ''): - log.debug('Using default password reset email plaintext template') - config['password_resets']['email_text'] = str(local_dir / 'email.txt') +if ( + "email_html" not in config["password_resets"] + or config["password_resets"]["email_html"] == "" +): + log.debug("Using default password reset email HTML template") + config["password_resets"]["email_html"] = str(local_dir / "email.html") +if ( + "email_text" not in config["password_resets"] + or config["password_resets"]["email_text"] == "" +): + log.debug("Using default password reset email plaintext template") + config["password_resets"]["email_text"] = str(local_dir / "email.txt") -if ('email_html' not in config['invite_emails'] or - config['invite_emails']['email_html'] == ''): - log.debug('Using default invite email HTML template') - config['invite_emails']['email_html'] = str(local_dir / - 'invite-email.html') -if ('email_text' not in config['invite_emails'] or - config['invite_emails']['email_text'] == ''): - log.debug('Using default invite email plaintext template') - config['invite_emails']['email_text'] = str(local_dir / - 'invite-email.txt') -if ('public_server' not in config['jellyfin'] or - config['jellyfin']['public_server'] == ''): - config['jellyfin']['public_server'] = config['jellyfin']['server'] +if ( + "email_html" not in config["invite_emails"] + or config["invite_emails"]["email_html"] == "" +): + log.debug("Using default invite email HTML template") + config["invite_emails"]["email_html"] = str(local_dir / "invite-email.html") +if ( + "email_text" not in config["invite_emails"] + or config["invite_emails"]["email_text"] == "" +): + log.debug("Using default invite email plaintext template") + config["invite_emails"]["email_text"] = str(local_dir / "invite-email.txt") +if ( + "public_server" not in config["jellyfin"] + or config["jellyfin"]["public_server"] == "" +): + config["jellyfin"]["public_server"] = config["jellyfin"]["server"] def main(): if args.get_defaults: import json from jellyfin_accounts.jf_api import Jellyfin - jf = Jellyfin(config['jellyfin']['server'], - config['jellyfin']['client'], - config['jellyfin']['version'], - config['jellyfin']['device'], - config['jellyfin']['device_id']) + + jf = Jellyfin( + config["jellyfin"]["server"], + config["jellyfin"]["client"], + config["jellyfin"]["version"], + config["jellyfin"]["device"], + config["jellyfin"]["device_id"], + ) print("NOTE: This can now be done through the web ui.") - print(""" + print( + """ This tool lets you grab various settings from a user, so that they can be applied every time a new account is - created. """) + created. """ + ) print("Step 1: User Policy.") - print(""" - A user policy stores a users permissions (e.g access rights and + print( + """ + A user policy stores a users permissions (e.g access rights and most of the other settings in the 'Profile' and 'Access' tabs - of a user). """) + of a user). """ + ) success = False - msg = "Get public users only or all users? (requires auth) [public/all]: " + msg = "Get public users only or all users? (requires auth) [public/all]: " public = False while not success: choice = input(msg) - if choice == 'public': + if choice == "public": public = True print("Make sure the user is publicly visible!") success = True - elif choice == 'all': - jf.authenticate(config['jellyfin']['username'], - config['jellyfin']['password']) + elif choice == "all": + jf.authenticate( + config["jellyfin"]["username"], config["jellyfin"]["password"] + ) public = False success = True users = jf.getUsers(public=public) @@ -207,69 +238,78 @@ def main(): success = False while not success: try: - user_index = int(input(">: "))-1 - policy = users[user_index]['Policy'] + user_index = int(input(">: ")) - 1 + policy = users[user_index]["Policy"] success = True except (ValueError, IndexError): pass data_store.user_template = policy print(f'Policy written to "{config["files"]["user_template"]}".') - print('In future, this policy will be copied to all new users.') - print('Step 2: Homescreen Layout') - print(""" + print("In future, this policy will be copied to all new users.") + print("Step 2: Homescreen Layout") + print( + """ You may want to customize the default layout of a new user's home screen. These settings can be applied to an account through - the 'Home' section in a user's settings. """) + the 'Home' section in a user's settings. """ + ) success = False while not success: choice = input("Grab the chosen user's homescreen layout? [y/n]: ") - if choice.lower() == 'y': - user_id = users[user_index]['Id'] - configuration = users[user_index]['Configuration'] + if choice.lower() == "y": + user_id = users[user_index]["Id"] + configuration = users[user_index]["Configuration"] display_prefs = jf.getDisplayPreferences(user_id) data_store.user_configuration = configuration - print(f'Configuration written to "{config["files"]["user_configuration"]}".') + print( + f'Configuration written to "{config["files"]["user_configuration"]}".' + ) data_store.user_displayprefs = display_prefs - print(f'Display Prefs written to "{config["files"]["user_displayprefs"]}".') + print( + f'Display Prefs written to "{config["files"]["user_displayprefs"]}".' + ) success = True - elif choice.lower() == 'n': + elif choice.lower() == "n": success = True else: + def signal_handler(sig, frame): - print('Quitting...') + print("Quitting...") sys.exit(0) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) global app app = Flask(__name__, root_path=str(local_dir)) - app.config['DEBUG'] = config.getboolean('ui', 'debug') - app.config['SECRET_KEY'] = secrets.token_urlsafe(16) + app.config["DEBUG"] = config.getboolean("ui", "debug") + app.config["SECRET_KEY"] = secrets.token_urlsafe(16) from waitress import serve + if first_run: import jellyfin_accounts.setup - host = config['ui']['host'] - port = config['ui']['port'] - log.info('Starting web UI for first run setup...') - serve(app, - host=host, - port=port) + + host = config["ui"]["host"] + port = config["ui"]["port"] + log.info("Starting web UI for first run setup...") + serve(app, host=host, port=port) else: import jellyfin_accounts.web_api import jellyfin_accounts.web - host = config['ui']['host'] - port = config['ui']['port'] - log.info(f'Starting web UI on {host}:{port}') - if config.getboolean('password_resets', 'enabled'): + + host = config["ui"]["host"] + port = config["ui"]["port"] + log.info(f"Starting web UI on {host}:{port}") + if config.getboolean("password_resets", "enabled"): + def start_pwr(): import jellyfin_accounts.pw_reset + jellyfin_accounts.pw_reset.start() + pwr = threading.Thread(target=start_pwr, daemon=True) - log.info('Starting email thread') + log.info("Starting email thread") pwr.start() - serve(app, - host=host, - port=int(port)) + serve(app, host=host, port=int(port)) diff --git a/jellyfin_accounts/data_store.py b/jellyfin_accounts/data_store.py index 54d5517..9b8f7ef 100644 --- a/jellyfin_accounts/data_store.py +++ b/jellyfin_accounts/data_store.py @@ -1,20 +1,26 @@ import json import datetime + class JSONFile(dict): + """ + Behaves like a dictionary, but automatically + reads and writes to a JSON file (most of the time). + """ + @staticmethod def readJSON(path): try: - with open(path, 'r') as f: + with open(path, "r") as f: return json.load(f) except FileNotFoundError: return {} - + @staticmethod def writeJSON(path, data): - with open(path, 'w') as f: + with open(path, "w") as f: return f.write(json.dumps(data, indent=4, default=str)) - + def __init__(self, path, data=None): self.path = path if data is None: @@ -30,14 +36,14 @@ class JSONFile(dict): def __setitem__(self, key, value): data = self.readJSON(self.path) data[key] = value - self.writeJSON(self.path, data) + self.writeJSON(self.path, data) super(JSONFile, self).__init__(data) def __delitem__(self, key): data = self.readJSON(self.path) super(JSONFile, self).__init__(data) del data[key] - self.writeJSON(self.path, data) + self.writeJSON(self.path, data) super(JSONFile, self).__delitem__(key) def __str__(self): @@ -46,18 +52,15 @@ class JSONFile(dict): class JSONStorage: - def __init__(self, - emails, - invites, - user_template, - user_displayprefs, - user_configuration): + def __init__( + self, emails, invites, user_template, user_displayprefs, user_configuration + ): self.emails = JSONFile(path=emails) self.invites = JSONFile(path=invites) self.user_template = JSONFile(path=user_template) self.user_displayprefs = JSONFile(path=user_displayprefs) self.user_configuration = JSONFile(path=user_configuration) - + def __setattr__(self, name, value): if hasattr(self, name): path = self.__dict__[name].path diff --git a/jellyfin_accounts/email.py b/jellyfin_accounts/email.py index e5ab2b2..baf7536 100644 --- a/jellyfin_accounts/email.py +++ b/jellyfin_accounts/email.py @@ -12,97 +12,109 @@ from jellyfin_accounts import config from jellyfin_accounts import email_log as log -class Email(): +class Email: def __init__(self, address): self.address = address - log.debug(f'{self.address}: Creating email') + log.debug(f"{self.address}: Creating email") self.content = {} - self.from_address = config['email']['address'] - self.from_name = config['email']['from'] - log.debug(( - f'{self.address}: Sending from {self.from_address} ' + - f'({self.from_name})')) + self.from_address = config["email"]["address"] + self.from_name = config["email"]["from"] + log.debug( + ( + f"{self.address}: Sending from {self.from_address} " + + f"({self.from_name})" + ) + ) def pretty_time(self, expiry): current_time = datetime.datetime.now() - date = expiry.strftime(config['email']['date_format']) - if config.getboolean('email', 'use_24h'): - log.debug(f'{self.address}: Using 24h time') - time = expiry.strftime('%H:%M') + date = expiry.strftime(config["email"]["date_format"]) + if config.getboolean("email", "use_24h"): + log.debug(f"{self.address}: Using 24h time") + time = expiry.strftime("%H:%M") else: - log.debug(f'{self.address}: Using 12h time') - time = expiry.strftime('%-I:%M %p') + log.debug(f"{self.address}: Using 12h time") + time = expiry.strftime("%-I:%M %p") expiry_delta = (expiry - current_time).seconds - expires_in = {'hours': expiry_delta//3600, - 'minutes': (expiry_delta//60) % 60} - if expires_in['hours'] == 0: + expires_in = { + "hours": expiry_delta // 3600, + "minutes": (expiry_delta // 60) % 60, + } + if expires_in["hours"] == 0: expires_in = f'{str(expires_in["minutes"])}m' else: - expires_in = (f'{str(expires_in["hours"])}h ' + - f'{str(expires_in["minutes"])}m') - log.debug(f'{self.address}: Expires in {expires_in}') - return {'date': date, 'time': time, 'expires_in': expires_in} + expires_in = ( + f'{str(expires_in["hours"])}h ' + f'{str(expires_in["minutes"])}m' + ) + log.debug(f"{self.address}: Expires in {expires_in}") + return {"date": date, "time": time, "expires_in": expires_in} def construct_invite(self, invite): - self.subject = config['invite_emails']['subject'] - log.debug(f'{self.address}: Using subject {self.subject}') - log.debug(f'{self.address}: Constructing email content') - expiry = invite['expiry'] + self.subject = config["invite_emails"]["subject"] + log.debug(f"{self.address}: Using subject {self.subject}") + log.debug(f"{self.address}: Constructing email content") + expiry = invite["expiry"] expiry.replace(tzinfo=None) pretty = self.pretty_time(expiry) - email_message = config['email']['message'] - invite_link = config['invite_emails']['url_base'] - invite_link += '/' + invite['code'] - for key in ['text', 'html']: - sp = Path(config['invite_emails']['email_' + key]) / '..' - sp = str(sp.resolve()) + '/' + email_message = config["email"]["message"] + invite_link = config["invite_emails"]["url_base"] + invite_link += "/" + invite["code"] + for key in ["text", "html"]: + sp = Path(config["invite_emails"]["email_" + key]) / ".." + sp = str(sp.resolve()) + "/" template_loader = FileSystemLoader(searchpath=sp) template_env = Environment(loader=template_loader) - fname = Path(config['invite_emails']['email_' + key]).name + fname = Path(config["invite_emails"]["email_" + key]).name template = template_env.get_template(fname) - c = template.render(expiry_date=pretty['date'], - expiry_time=pretty['time'], - expires_in=pretty['expires_in'], - invite_link=invite_link, - message=email_message) + c = template.render( + expiry_date=pretty["date"], + expiry_time=pretty["time"], + expires_in=pretty["expires_in"], + invite_link=invite_link, + message=email_message, + ) self.content[key] = c - log.info(f'{self.address}: {key} constructed') + log.info(f"{self.address}: {key} constructed") def construct_reset(self, reset): - self.subject = config['password_resets']['subject'] - log.debug(f'{self.address}: Using subject {self.subject}') - log.debug(f'{self.address}: Constructing email content') + self.subject = config["password_resets"]["subject"] + log.debug(f"{self.address}: Using subject {self.subject}") + log.debug(f"{self.address}: Constructing email content") try: - expiry = date_parser.parse(reset['ExpirationDate']) + expiry = date_parser.parse(reset["ExpirationDate"]) expiry = expiry.replace(tzinfo=None) except: log.error(f"{self.address}: Couldn't parse expiry time") return False current_time = datetime.datetime.now() if expiry >= current_time: - log.debug(f'{self.address}: Invite valid') + log.debug(f"{self.address}: Invite valid") pretty = self.pretty_time(expiry) - email_message = config['email']['message'] - for key in ['text', 'html']: - sp = Path(config['password_resets']['email_' + key]) / '..' - sp = str(sp.resolve()) + '/' + email_message = config["email"]["message"] + for key in ["text", "html"]: + sp = Path(config["password_resets"]["email_" + key]) / ".." + sp = str(sp.resolve()) + "/" template_loader = FileSystemLoader(searchpath=sp) template_env = Environment(loader=template_loader) - fname = Path(config['password_resets']['email_' + key]).name + fname = Path(config["password_resets"]["email_" + key]).name template = template_env.get_template(fname) - c = template.render(username=reset['UserName'], - expiry_date=pretty['date'], - expiry_time=pretty['time'], - expires_in=pretty['expires_in'], - pin=reset['Pin'], - message=email_message) + c = template.render( + username=reset["UserName"], + expiry_date=pretty["date"], + expiry_time=pretty["time"], + expires_in=pretty["expires_in"], + pin=reset["Pin"], + message=email_message, + ) self.content[key] = c - log.info(f'{self.address}: {key} constructed') + log.info(f"{self.address}: {key} constructed") return True else: - err = ((f"{self.address}: " + - "Reset has reportedly already expired. " + - "Ensure timezones are correctly configured.")) + err = ( + f"{self.address}: " + + "Reset has reportedly already expired. " + + "Ensure timezones are correctly configured." + ) log.error(err) return False @@ -110,71 +122,74 @@ class Email(): class Mailgun(Email): def __init__(self, address): super().__init__(address) - self.api_url = config['mailgun']['api_url'] - self.api_key = config['mailgun']['api_key'] - self.from_mg = f'{self.from_name} <{self.from_address}>' + self.api_url = config["mailgun"]["api_url"] + self.api_key = config["mailgun"]["api_key"] + self.from_mg = f"{self.from_name} <{self.from_address}>" def send(self): - response = requests.post(self.api_url, - auth=("api", self.api_key), - data={"from": self.from_mg, - "to": [self.address], - "subject": self.subject, - "text": self.content['text'], - "html": self.content['html']}) + response = requests.post( + self.api_url, + auth=("api", self.api_key), + data={ + "from": self.from_mg, + "to": [self.address], + "subject": self.subject, + "text": self.content["text"], + "html": self.content["html"], + }, + ) if response.ok: - log.info(f'{self.address}: Sent via mailgun.') + log.info(f"{self.address}: Sent via mailgun.") return True - log.debug(f'{self.address}: Mailgun: {response.status_code}') + log.debug(f"{self.address}: Mailgun: {response.status_code}") return response class Smtp(Email): def __init__(self, address): super().__init__(address) - self.server = config['smtp']['server'] - self.password = config['smtp']['password'] + self.server = config["smtp"]["server"] + self.password = config["smtp"]["password"] try: - self.port = int(config['smtp']['port']) + self.port = int(config["smtp"]["port"]) except ValueError: self.port = 465 - log.debug(f'{self.address}: Defaulting to port {self.port}') + log.debug(f"{self.address}: Defaulting to port {self.port}") def send(self): message = MIMEMultipart("alternative") message["Subject"] = self.subject message["From"] = self.from_address message["To"] = self.address - text = MIMEText(self.content['text'], 'plain') - html = MIMEText(self.content['html'], 'html') + text = MIMEText(self.content["text"], "plain") + html = MIMEText(self.content["html"], "html") message.attach(text) message.attach(html) try: - if config['smtp']['encryption'] == 'ssl_tls': + if config["smtp"]["encryption"] == "ssl_tls": self.context = ssl.create_default_context() - with smtplib.SMTP_SSL(self.server, - self.port, - context=self.context) as server: + with smtplib.SMTP_SSL( + self.server, self.port, context=self.context + ) as server: server.ehlo() server.login(self.from_address, self.password) - server.sendmail(self.from_address, - self.address, - message.as_string()) - log.info(f'{self.address}: Sent via smtp (ssl/tls)') + server.sendmail( + self.from_address, self.address, message.as_string() + ) + log.info(f"{self.address}: Sent via smtp (ssl/tls)") return True - elif config['smtp']['encryption'] == 'starttls': - with smtplib.SMTP(self.server, - self.port) as server: + elif config["smtp"]["encryption"] == "starttls": + with smtplib.SMTP(self.server, self.port) as server: server.ehlo() server.starttls() server.login(self.from_address, self.password) - server.sendmail(self.from_address, - self.address, - message.as_string()) - log.info(f'{self.address}: Sent via smtp (starttls)') + server.sendmail( + self.from_address, self.address, message.as_string() + ) + log.info(f"{self.address}: Sent via smtp (starttls)") return True except Exception as e: - err = f'{self.address}: Failed to send via smtp: ' + err = f"{self.address}: Failed to send via smtp: " err += type(e).__name__ log.error(err) try: diff --git a/jellyfin_accounts/jf_api.py b/jellyfin_accounts/jf_api.py index 08229bd..8a59e73 100644 --- a/jellyfin_accounts/jf_api.py +++ b/jellyfin_accounts/jf_api.py @@ -2,35 +2,48 @@ import requests import time + class Error(Exception): pass + class Jellyfin: """ Basic Jellyfin API client, providing account related function only. """ + class UserExistsError(Error): """ Thrown if a user already exists with the same name when creating an account. """ + pass + class UserNotFoundError(Error): """Thrown if account with specified user ID/name does not exist.""" + pass + class AuthenticationError(Error): """Thrown if authentication with Jellyfin fails.""" + pass + class AuthenticationRequiredError(Error): """ Thrown if privileged action is attempted without authentication. """ + pass + class UnknownError(Error): """ Thrown if i've been too lazy to figure out an error's meaning. """ + pass + def __init__(self, server, client, version, device, deviceId): """ Initializes the Jellyfin object. All parameters except server @@ -58,40 +71,43 @@ class Jellyfin: self.auth += f"DeviceId={self.deviceId}, " self.auth += f"Version={self.version}" self.header = { - "Accept": "application/json", - "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "X-Application": f"{self.client}/{self.version}", - "Accept-Charset": "UTF-8,*", - "Accept-encoding": "gzip", - "User-Agent": self.useragent, - "X-Emby-Authorization": self.auth + "Accept": "application/json", + "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Application": f"{self.client}/{self.version}", + "Accept-Charset": "UTF-8,*", + "Accept-encoding": "gzip", + "User-Agent": self.useragent, + "X-Emby-Authorization": self.auth, } - def getUsers(self, username="all", id="all", public=True): + self.info = requests.get(self.server + "/System/Info/Public").json() + + def getUsers(self, username: str = "all", userId: str = "all", public: bool = True): """ Returns details on user(s), such as ID, Name, Policy. :param username: (optional) Username to get info about. Leave blank to get all users. - :param id: (optional) User ID to get info about. + :param userId: (optional) User ID to get info about. Leave blank to get all users. :param public: True = Get publicly visible users only (no auth required), False = Get all users (auth required). """ if public is True: if (time.time() - self.userCachePublicAge) >= self.timeout: - response = requests.get(self.server+"/emby/Users/Public").json() + response = requests.get(self.server + "/emby/Users/Public").json() self.userCachePublic = response self.userCachePublicAge = time.time() else: response = self.userCachePublic - elif (public is False and - hasattr(self, 'username') and - hasattr(self, 'password')): + elif ( + public is False and hasattr(self, "username") and hasattr(self, "password") + ): if (time.time() - self.userCacheAge) >= self.timeout: - response = requests.get(self.server+"/emby/Users", - headers=self.header, - params={'Username': self.username, - 'Pw': self.password}) + response = requests.get( + self.server + "/emby/Users", + headers=self.header, + params={"Username": self.username, "Pw": self.password}, + ) if response.status_code == 200: response = response.json() self.userCache = response @@ -99,19 +115,19 @@ class Jellyfin: else: try: self.authenticate(self.username, self.password) - return self.getUsers(username, id, public) + return self.getUsers(username, userId, public) except self.AuthenticationError: raise self.AuthenticationRequiredError else: response = self.userCache else: raise self.AuthenticationRequiredError - if username == "all" and id == "all": + if username == "all" and userId == "all": return response - elif id == "all": + elif userId == "all": match = False for user in response: - if user['Name'] == username: + if user["Name"] == username: match = True return user if not match: @@ -119,12 +135,13 @@ class Jellyfin: else: match = False for user in response: - if user['Id'] == id: + if user["Id"] == userId: match = True return user if not match: raise self.UserNotFoundError - def authenticate(self, username, password): + + def authenticate(self, username: str, password: str): """ Authenticates by name with Jellyfin. @@ -133,125 +150,154 @@ class Jellyfin: """ self.username = username self.password = password - response = requests.post(self.server+"/emby/Users/AuthenticateByName", - headers=self.header, - params={'Username': self.username, - 'Pw': self.password}) + response = requests.post( + self.server + "/emby/Users/AuthenticateByName", + headers=self.header, + params={"Username": self.username, "Pw": self.password}, + ) if response.status_code == 200: json = response.json() - self.userId = json['User']['Id'] - self.accessToken = json['AccessToken'] + self.userId = json["User"]["Id"] + self.accessToken = json["AccessToken"] self.auth = "MediaBrowser " self.auth += f"Client={self.client}, " self.auth += f"Device={self.device}, " self.auth += f"DeviceId={self.deviceId}, " self.auth += f"Version={self.version}" self.auth += f", Token={self.accessToken}" - self.header['X-Emby-Authorization'] = self.auth + self.header["X-Emby-Authorization"] = self.auth + self.info = requests.get( + self.server + "/System/Info", headers=self.header + ).json() return True else: raise self.AuthenticationError - def setPolicy(self, userId, policy): + + def setPolicy(self, userId: str, policy: dict): """ Sets a user's policy (Admin rights, Library Access, etc.) by user ID. :param userId: ID of the user to modify. :param policy: User policy in dictionary form. """ - return requests.post(self.server+"/Users/"+userId+"/Policy", - headers=self.header, - params=policy) - def newUser(self, username, password): + return requests.post( + self.server + "/Users/" + userId + "/Policy", + headers=self.header, + params=policy, + ) + + def newUser(self, username: str, password: str): for user in self.getUsers(): - if user['Name'] == username: + if user["Name"] == username: raise self.UserExistsError - response = requests.post(self.server+"/emby/Users/New", - headers=self.header, - params={'Name': username, - 'Password': password}) + response = requests.post( + self.server + "/emby/Users/New", + headers=self.header, + params={"Name": username, "Password": password}, + ) if response.status_code == 401: - if hasattr(self, 'username') and hasattr(self, 'password'): + if hasattr(self, "username") and hasattr(self, "password"): self.authenticate(self.username, self.password) return self.newUser(username, password) else: raise self.AuthenticationRequiredError return response - def getViewOrder(self, userId, public=True): + + def getViewOrder(self, userId: str, public: bool = True): if not public: - param = '?IncludeHidden=true' + param = "?IncludeHidden=true" else: - param = '' - views = requests.get(self.server+"/Users/"+userId+"/Views"+param, - headers=self.header).json()['Items'] + param = "" + views = requests.get( + self.server + "/Users/" + userId + "/Views" + param, headers=self.header + ).json()["Items"] orderedViews = [] for library in views: - orderedViews.append(library['Id']) + orderedViews.append(library["Id"]) return orderedViews - def setConfiguration(self, userId, configuration): + + def setConfiguration(self, userId: str, configuration: dict): """ Sets a user's configuration (Settings the user can change themselves). :param userId: ID of the user to modify. :param configuration: Configuration to write in dictionary form. """ - resp = requests.post(self.server+"/Users/"+userId+"/Configuration", - headers=self.header, - params=configuration) - if (resp.status_code == 200 or - resp.status_code == 204): + resp = requests.post( + self.server + "/Users/" + userId + "/Configuration", + headers=self.header, + params=configuration, + ) + if resp.status_code == 200 or resp.status_code == 204: return True elif resp.status_code == 401: - if hasattr(self, 'username') and hasattr(self, 'password'): + if hasattr(self, "username") and hasattr(self, "password"): self.authenticate(self.username, self.password) return self.setConfiguration(userId, configuration) else: raise self.AuthenticationRequiredError else: raise self.UnknownError - def getConfiguration(self, username="all", id="all"): + + def getConfiguration(self, username: str = "all", userId: str = "all"): """ Gets a user's Configuration. This can also be found in getUsers if public is set to False. :param username: The user's username. - :param id: The user's ID. + :param userId: The user's ID. """ - return self.getUsers(username=username, - id=id, - public=False)['Configuration'] - def getDisplayPreferences(self, userId): + return self.getUsers(username=username, userId=userId, public=False)[ + "Configuration" + ] + + def getDisplayPreferences(self, userId: str): """ Gets a user's Display Preferences (Home layout). :param userId: The user's ID. """ - resp = requests.get((self.server+"/DisplayPreferences/usersettings" + - "?userId="+userId+"&client=emby"), - headers=self.header) + resp = requests.get( + ( + self.server + + "/DisplayPreferences/usersettings" + + "?userId=" + + userId + + "&client=emby" + ), + headers=self.header, + ) if resp.status_code == 200: return resp.json() elif resp.status_code == 401: - if hasattr(self, 'username') and hasattr(self, 'password'): + if hasattr(self, "username") and hasattr(self, "password"): self.authenticate(self.username, self.password) return self.getDisplayPreferences(userId) else: raise self.AuthenticationRequiredError else: raise self.UnknownError - def setDisplayPreferences(self, userId, preferences): + + def setDisplayPreferences(self, userId: str, preferences: dict): """ Sets a user's Display Preferences (Home layout). :param userId: The user's ID. :param preferences: The preferences to set. """ tempheader = self.header - tempheader['Content-type'] = 'application/json' - resp = requests.post((self.server+"/DisplayPreferences/usersettings" + - "?userId="+userId+"&client=emby"), - headers=tempheader, - json=preferences) - if (resp.status_code == 200 or - resp.status_code == 204): + tempheader["Content-type"] = "application/json" + resp = requests.post( + ( + self.server + + "/DisplayPreferences/usersettings" + + "?userId=" + + userId + + "&client=emby" + ), + headers=tempheader, + json=preferences, + ) + if resp.status_code == 200 or resp.status_code == 204: return True elif resp.status_code == 401: - if hasattr(self, 'username') and hasattr(self, 'password'): + if hasattr(self, "username") and hasattr(self, "password"): self.authenticate(self.username, self.password) return self.setDisplayPreferences(userId, preferences) else: diff --git a/jellyfin_accounts/login.py b/jellyfin_accounts/login.py index e6dd78e..9df81b3 100644 --- a/jellyfin_accounts/login.py +++ b/jellyfin_accounts/login.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -# from flask import g from flask_httpauth import HTTPBasicAuth -from itsdangerous import (TimedJSONWebSignatureSerializer - as Serializer, BadSignature, SignatureExpired) +from itsdangerous import ( + TimedJSONWebSignatureSerializer as Serializer, + BadSignature, + SignatureExpired, +) from passlib.apps import custom_app_context as pwd_context import uuid from jellyfin_accounts import config, app, g @@ -11,13 +13,16 @@ from jellyfin_accounts import auth_log as log from jellyfin_accounts.jf_api import Jellyfin from jellyfin_accounts.web_api import jf -auth_jf = Jellyfin(config['jellyfin']['server'], - config['jellyfin']['client'], - config['jellyfin']['version'], - config['jellyfin']['device'], - config['jellyfin']['device_id'] + '_authClient') +auth_jf = Jellyfin( + config["jellyfin"]["server"], + config["jellyfin"]["client"], + config["jellyfin"]["version"], + config["jellyfin"]["device"], + config["jellyfin"]["device_id"] + "_authClient", +) -class Account(): + +class Account: def __init__(self, username=None, password=None): self.username = username if password is not None: @@ -25,10 +30,12 @@ class Account(): self.id = str(uuid.uuid4()) self.jf = False elif username is not None: - jf.authenticate(config['jellyfin']['username'], - config['jellyfin']['password']) - self.id = jf.getUsers(self.username, public=False)['Id'] + jf.authenticate( + config["jellyfin"]["username"], config["jellyfin"]["password"] + ) + self.id = jf.getUsers(self.username, public=False)["Id"] self.jf = True + def verify_password(self, password): if not self.jf: return pwd_context.verify(password, self.password_hash) @@ -37,59 +44,60 @@ class Account(): return auth_jf.authenticate(self.username, password) except Jellyfin.AuthenticationError: return False + def generate_token(self, expiration=1200): - s = Serializer(app.config['SECRET_KEY'], expires_in=expiration) + s = Serializer(app.config["SECRET_KEY"], expires_in=expiration) log.debug(self.id) - return s.dumps({ 'id': self.id }) + return s.dumps({"id": self.id}) @staticmethod def verify_token(token, accounts): - log.debug(f'verifying token {token}') - s = Serializer(app.config['SECRET_KEY']) + log.debug(f"verifying token {token}") + s = Serializer(app.config["SECRET_KEY"]) try: data = s.loads(token) except SignatureExpired: return None except BadSignature: return None - if config.getboolean('ui', 'jellyfin_login'): + if config.getboolean("ui", "jellyfin_login"): for account in accounts: - if data['id'] == accounts[account].id: + if data["id"] == accounts[account].id: return account else: - return accounts['adminAccount'] - + return accounts["adminAccount"] auth = HTTPBasicAuth() accounts = {} -if config.getboolean('ui', 'jellyfin_login'): - log.debug('Using jellyfin for admin authentication') +if config.getboolean("ui", "jellyfin_login"): + log.debug("Using jellyfin for admin authentication") else: - log.debug('Using configured login details for admin authentication') - accounts['adminAccount'] = Account(config['ui']['username'], - config['ui']['password']) + log.debug("Using configured login details for admin authentication") + accounts["adminAccount"] = Account( + config["ui"]["username"], config["ui"]["password"] + ) @auth.verify_password def verify_password(username, password): user = None verified = False - log.debug('Verifying auth') - if config.getboolean('ui', 'jellyfin_login'): + log.debug("Verifying auth") + if config.getboolean("ui", "jellyfin_login"): try: jf_user = jf.getUsers(username, public=False) - id = jf_user['Id'] + id = jf_user["Id"] user = accounts[id] except KeyError: - if config.getboolean('ui', 'admin_only'): - if jf_user['Policy']['IsAdministrator']: + if config.getboolean("ui", "admin_only"): + if jf_user["Policy"]["IsAdministrator"]: user = Account(username) accounts[id] = user else: - log.debug(f'User {username} not admin.') + log.debug(f"User {username} not admin.") return False else: user = Account(username) @@ -99,11 +107,11 @@ def verify_password(username, password): if user: verified = True if not user: - log.debug(f'User {username} not found on Jellyfin') + log.debug(f"User {username} not found on Jellyfin") return False else: - user = accounts['adminAccount'] - verified = Account().verify_token(username, accounts) + user = accounts["adminAccount"] + verified = Account().verify_token(username, accounts) if not verified: if username == user.username and user.verify_password(password): g.user = user @@ -115,6 +123,3 @@ def verify_password(username, password): g.user = user log.debug("HTTPAuth Allowed") return True - - - diff --git a/jellyfin_accounts/pw_reset.py b/jellyfin_accounts/pw_reset.py index 1403924..293f25a 100755 --- a/jellyfin_accounts/pw_reset.py +++ b/jellyfin_accounts/pw_reset.py @@ -8,7 +8,6 @@ from jellyfin_accounts import config, data_store from jellyfin_accounts import email_log as log - class Watcher: def __init__(self, dir): self.observer = Observer() @@ -20,13 +19,13 @@ class Watcher: try: self.observer.start() except NotADirectoryError: - log.error(f'Directory {self.dir} does not exist') + log.error(f"Directory {self.dir} does not exist") try: while True: time.sleep(5) except: self.observer.stop() - log.info('Watchdog stopped') + log.info("Watchdog stopped") class Handler(FileSystemEventHandler): @@ -34,33 +33,35 @@ class Handler(FileSystemEventHandler): def on_any_event(event): if event.is_directory: return None - elif (event.event_type == 'modified' and - 'passwordreset' in event.src_path): - log.debug(f'Password reset file: {event.src_path}') + elif event.event_type == "modified" and "passwordreset" in event.src_path: + log.debug(f"Password reset file: {event.src_path}") time.sleep(1) - with open(event.src_path, 'r') as f: + with open(event.src_path, "r") as f: reset = json.load(f) log.info(f'New password reset for {reset["UserName"]}') try: - id = jf.getUsers(reset['UserName'], public=False)['Id'] + id = jf.getUsers(reset["UserName"], public=False)["Id"] address = data_store.emails[id] - if address != '': - method = config['email']['method'] - if method == 'mailgun': + if address != "": + method = config["email"]["method"] + if method == "mailgun": email = Mailgun(address) - elif method == 'smtp': + elif method == "smtp": email = Smtp(address) if email.construct_reset(reset): email.send() else: raise IndexError - except (FileNotFoundError, - json.decoder.JSONDecodeError, - IndexError) as e: - err = f'{address}: Failed: ' + type(e).__name__ + except ( + FileNotFoundError, + json.decoder.JSONDecodeError, + IndexError, + ) as e: + err = f"{address}: Failed: " + type(e).__name__ log.error(err) + def start(): log.info(f'Monitoring {config["password_resets"]["watch_directory"]}') - w = Watcher(config['password_resets']['watch_directory']) + w = Watcher(config["password_resets"]["watch_directory"]) w.run() diff --git a/jellyfin_accounts/setup.py b/jellyfin_accounts/setup.py index 6ddcfd1..560c582 100644 --- a/jellyfin_accounts/setup.py +++ b/jellyfin_accounts/setup.py @@ -3,48 +3,40 @@ from configparser import RawConfigParser from jellyfin_accounts.jf_api import Jellyfin from jellyfin_accounts import config, config_path, app, first_run from jellyfin_accounts import web_log as log +from jellyfin_accounts.web_api import resp import os if first_run: - def resp(success=True, code=500): - if success: - r = jsonify({'success': True}) - r.status_code = 200 - else: - r = jsonify({'success': False}) - r.status_code = code - return r def tempJF(server): - return Jellyfin(server, - config['jellyfin']['client'], - config['jellyfin']['version'], - config['jellyfin']['device'] + '_temp', - config['jellyfin']['device_id'] + '_temp') + return Jellyfin( + server, + config["jellyfin"]["client"], + config["jellyfin"]["version"], + config["jellyfin"]["device"] + "_temp", + config["jellyfin"]["device_id"] + "_temp", + ) @app.errorhandler(404) def page_not_found(e): - return render_template('404.html'), 404 + return render_template("404.html"), 404 - @app.route('/', methods=['GET', 'POST']) + @app.route("/", methods=["GET", "POST"]) def setup(): - return render_template('setup.html') + return render_template("setup.html") - - @app.route('/') + @app.route("/") def static_proxy(path): - if 'html' not in path: + if "html" not in path: return app.send_static_file(path) else: - return render_template('404.html'), 404 + return render_template("404.html"), 404 - - @app.route('/modifyConfig', methods=['POST']) + @app.route("/modifyConfig", methods=["POST"]) def modifyConfig(): - log.info('Config modification requested') + log.info("Config modification requested") data = request.get_json() - temp_config = RawConfigParser(comment_prefixes='/', - allow_no_value=True) + temp_config = RawConfigParser(comment_prefixes="/", allow_no_value=True) temp_config.read(config_path) for section in data: if section in temp_config: @@ -52,24 +44,23 @@ if first_run: if item in temp_config[section]: temp_config[section][item] = data[section][item] data[section][item] = True - log.debug(f'{section}/{item} modified') + log.debug(f"{section}/{item} modified") else: data[section][item] = False - log.debug(f'{section}/{item} does not exist in config') - with open(config_path, 'w') as config_file: + log.debug(f"{section}/{item} does not exist in config") + with open(config_path, "w") as config_file: temp_config.write(config_file) - log.debug('Config written') + log.debug("Config written") + # ugly exit, sorry os._exit(1) return resp() - - @app.route('/testJF', methods=['GET', 'POST']) + @app.route("/testJF", methods=["GET", "POST"]) def testJF(): data = request.get_json() - tempjf = tempJF(data['jfHost']) + tempjf = tempJF(data["jfHost"]) try: - tempjf.authenticate(data['jfUser'], - data['jfPassword']) + tempjf.authenticate(data["jfUser"], data["jfPassword"]) tempjf.getUsers(public=False) return resp() except: diff --git a/jellyfin_accounts/validate_password.py b/jellyfin_accounts/validate_password.py index eb166cf..8837d2a 100644 --- a/jellyfin_accounts/validate_password.py +++ b/jellyfin_accounts/validate_password.py @@ -1,35 +1,42 @@ specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')', '<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']'] + class PasswordValidator: def __init__(self, min_length, upper, lower, number, special): - self.criteria = {'characters': int(min_length), - 'uppercase characters': int(upper), - 'lowercase characters': int(lower), - 'numbers': int(number), - 'special characters': int(special)} + self.criteria = { + "characters": int(min_length), + "uppercase characters": int(upper), + "lowercase characters": int(lower), + "numbers": int(number), + "special characters": int(special), + } + def validate(self, password): - count = {'characters': 0, - 'uppercase characters': 0, - 'lowercase characters': 0, - 'numbers': 0, - 'special characters': 0} + count = { + "characters": 0, + "uppercase characters": 0, + "lowercase characters": 0, + "numbers": 0, + "special characters": 0, + } for c in password: - count['characters'] += 1 + count["characters"] += 1 if c.isupper(): - count['uppercase characters'] += 1 + count["uppercase characters"] += 1 elif c.islower(): - count['lowercase characters'] += 1 + count["lowercase characters"] += 1 elif c.isnumeric(): - count['numbers'] += 1 + count["numbers"] += 1 elif c in specials: - count['special characters'] += 1 + count["special characters"] += 1 for criterion in count: if count[criterion] < self.criteria[criterion]: count[criterion] = False else: count[criterion] = True return count + def getCriteria(self): lines = {} for criterion in self.criteria: @@ -42,8 +49,3 @@ class PasswordValidator: text += criterion lines[criterion] = text return lines - - - - - diff --git a/jellyfin_accounts/web.py b/jellyfin_accounts/web.py index 6d7bcf1..e9c64bb 100644 --- a/jellyfin_accounts/web.py +++ b/jellyfin_accounts/web.py @@ -8,63 +8,76 @@ from jellyfin_accounts.web_api import checkInvite, validator @app.errorhandler(404) def page_not_found(e): - return render_template('404.html', - css_href=css['href'], - css_integrity=css['integrity'], - css_crossorigin=css['crossorigin'], - contactMessage=config['ui']['contact_message']), 404 + return ( + render_template( + "404.html", + css_href=css["href"], + css_integrity=css["integrity"], + css_crossorigin=css["crossorigin"], + contactMessage=config["ui"]["contact_message"], + ), + 404, + ) -@app.route('/', methods=['GET', 'POST']) +@app.route("/", methods=["GET", "POST"]) def admin(): # return app.send_static_file('admin.html') - return render_template('admin.html', - css_href=css['href'], - css_integrity=css['integrity'], - css_crossorigin=css['crossorigin'], - contactMessage='', - email_enabled=config.getboolean( - 'invite_emails', 'enabled')) + return render_template( + "admin.html", + css_href=css["href"], + css_integrity=css["integrity"], + css_crossorigin=css["crossorigin"], + contactMessage="", + email_enabled=config.getboolean("invite_emails", "enabled"), + ) -@app.route('/') +@app.route("/") def static_proxy(path): - if 'html' not in path: + if "html" not in path: return app.send_static_file(path) - return render_template('404.html', - css_href=css['href'], - css_integrity=css['integrity'], - css_crossorigin=css['crossorigin'], - contactMessage=config['ui']['contact_message']), 404 + return ( + render_template( + "404.html", + css_href=css["href"], + css_integrity=css["integrity"], + css_crossorigin=css["crossorigin"], + contactMessage=config["ui"]["contact_message"], + ), + 404, + ) -@app.route('/invite/') +@app.route("/invite/") def inviteProxy(path): if checkInvite(path): - log.info(f'Invite {path} used to request form') + log.info(f"Invite {path} used to request form") try: - email = data_store.invites[path]['email'] + email = data_store.invites[path]["email"] except KeyError: - email = '' - return render_template('form.html', - css_href=css['href'], - css_integrity=css['integrity'], - css_crossorigin=css['crossorigin'], - contactMessage=config['ui']['contact_message'], - helpMessage=config['ui']['help_message'], - successMessage=config['ui']['success_message'], - jfLink=config['jellyfin']['public_server'], - validate=config.getboolean( - 'password_validation', - 'enabled'), - requirements=validator.getCriteria(), - email=email) - elif 'admin.html' not in path and 'admin.html' not in path: + email = "" + return render_template( + "form.html", + css_href=css["href"], + css_integrity=css["integrity"], + css_crossorigin=css["crossorigin"], + contactMessage=config["ui"]["contact_message"], + helpMessage=config["ui"]["help_message"], + successMessage=config["ui"]["success_message"], + jfLink=config["jellyfin"]["public_server"], + validate=config.getboolean("password_validation", "enabled"), + requirements=validator.getCriteria(), + email=email, + ) + elif "admin.html" not in path and "admin.html" not in path: return app.send_static_file(path) else: - log.debug('Attempted use of invalid invite') - return render_template('invalidCode.html', - css_href=css['href'], - css_integrity=css['integrity'], - css_crossorigin=css['crossorigin'], - contactMessage=config['ui']['contact_message']) + log.debug("Attempted use of invalid invite") + return render_template( + "invalidCode.html", + css_href=css["href"], + css_integrity=css["integrity"], + css_crossorigin=css["crossorigin"], + contactMessage=config["ui"]["contact_message"], + ) diff --git a/jellyfin_accounts/web_api.py b/jellyfin_accounts/web_api.py index c1e6248..f7606e5 100644 --- a/jellyfin_accounts/web_api.py +++ b/jellyfin_accounts/web_api.py @@ -8,27 +8,30 @@ from jellyfin_accounts import config, config_path, app, g, data_store from jellyfin_accounts import web_log as log from jellyfin_accounts.validate_password import PasswordValidator + def resp(success=True, code=500): if success: - r = jsonify({'success': True}) + r = jsonify({"success": True}) if code == 500: r.status_code = 200 else: r.status_code = code else: - r = jsonify({'success': False}) + r = jsonify({"success": False}) r.status_code = code return r + def checkInvite(code, delete=False): current_time = datetime.datetime.now() invites = dict(data_store.invites) match = False for invite in invites: - expiry = datetime.datetime.strptime(invites[invite]['valid_till'], - '%Y-%m-%dT%H:%M:%S.%f') + expiry = datetime.datetime.strptime( + invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f" + ) if current_time >= expiry: - log.debug(f'Housekeeping: Deleting old invite {invite}') + log.debug(f"Housekeeping: Deleting old invite {invite}") del data_store.invites[invite] elif invite == code: match = True @@ -36,34 +39,39 @@ def checkInvite(code, delete=False): del data_store.invites[code] return match -jf = Jellyfin(config['jellyfin']['server'], - config['jellyfin']['client'], - config['jellyfin']['version'], - config['jellyfin']['device'], - config['jellyfin']['device_id']) + +jf = Jellyfin( + config["jellyfin"]["server"], + config["jellyfin"]["client"], + config["jellyfin"]["version"], + config["jellyfin"]["device"], + config["jellyfin"]["device_id"], +) from jellyfin_accounts.login import auth -jf_address = config['jellyfin']['server'] +jf_address = config["jellyfin"]["server"] success = False for i in range(3): try: - jf.authenticate(config['jellyfin']['username'], - config['jellyfin']['password']) + jf.authenticate(config["jellyfin"]["username"], config["jellyfin"]["password"]) success = True - log.info(f'Successfully authenticated with {jf_address}') + log.info(f"Successfully authenticated with {jf_address}") break except Jellyfin.AuthenticationError: - log.error(f'Failed to authenticate with {jf_address}, Retrying...') + log.error(f"Failed to authenticate with {jf_address}, Retrying...") time.sleep(5) if not success: - log.error('Could not authenticate after 3 tries.') + log.error("Could not authenticate after 3 tries.") exit() +# Temporary fixes below. + + def switchToIds(): try: - with open(config['files']['emails'], 'r') as f: + with open(config["files"]["emails"], "r") as f: emails = json.load(f) except (FileNotFoundError, json.decoder.JSONDecodeError): emails = {} @@ -72,220 +80,251 @@ def switchToIds(): match = False for key in emails: for user in users: - if user['Name'] == key: + if user["Name"] == key: match = True - new_emails[user['Id']] = emails[key] - elif user['Id'] == key: - new_emails[user['Id']] = emails[key] + new_emails[user["Id"]] = emails[key] + elif user["Id"] == key: + new_emails[user["Id"]] = emails[key] if match: from pathlib import Path - email_file = Path(config['files']['emails']).name - log.info((f'{email_file} modified to use userID instead of ' + - 'usernames. These will be used in future.')) + + email_file = Path(config["files"]["emails"]).name + log.info( + ( + f"{email_file} modified to use userID instead of " + + "usernames. These will be used in future." + ) + ) emails = new_emails - with open(config['files']['emails'], 'w') as f: + with open(config["files"]["emails"], "w") as f: f.write(json.dumps(emails, indent=4)) # Temporary, switches emails.json over from using Usernames to User IDs. switchToIds() -if config.getboolean('password_validation', 'enabled'): - validator = PasswordValidator(config['password_validation']['min_length'], - config['password_validation']['upper'], - config['password_validation']['lower'], - config['password_validation']['number'], - config['password_validation']['special']) + +from packaging import version + +if ( + version.parse(jf.info["Version"]) >= version.parse("10.6.0") + and bool(data_store.user_template) is not False +): + log.info("Updating user_template for Jellyfin >= 10.6.0") + if ( + data_store.user_template["AuthenticationProviderId"] + == "Emby.Server.Implementations.Library.DefaultAuthenticationProvider" + ): + data_store.user_template[ + "AuthenticationProviderId" + ] = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider" + if ( + data_store.user_template["PasswordResetProviderId"] + == "Emby.Server.Implementations.Library.DefaultPasswordResetProvider" + ): + data_store.user_template[ + "PasswordResetProviderId" + ] = "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider" + + +if config.getboolean("password_validation", "enabled"): + validator = PasswordValidator( + config["password_validation"]["min_length"], + config["password_validation"]["upper"], + config["password_validation"]["lower"], + config["password_validation"]["number"], + config["password_validation"]["special"], + ) else: validator = PasswordValidator(0, 0, 0, 0, 0) -@app.route('/newUser', methods=['POST']) +@app.route("/newUser", methods=["POST"]) def newUser(): data = request.get_json() - log.debug('Attempted newUser') - if checkInvite(data['code']): - validation = validator.validate(data['password']) + log.debug("Attempted newUser") + if checkInvite(data["code"]): + validation = validator.validate(data["password"]) valid = True for criterion in validation: if validation[criterion] is False: valid = False if valid: - log.debug('User password valid') + log.debug("User password valid") try: - user = jf.newUser(data['username'], - data['password']) + user = jf.newUser(data["username"], data["password"]) except Jellyfin.UserExistsError: error = f'User already exists named {data["username"]}' log.debug(error) - return jsonify({'error': error}) + return jsonify({"error": error}) except: - return jsonify({'error': 'Unknown error'}) - checkInvite(data['code'], delete=True) + return jsonify({"error": "Unknown error"}) + checkInvite(data["code"], delete=True) if user.status_code == 200: try: policy = data_store.user_template if policy != {}: - jf.setPolicy(user.json()['Id'], policy) + jf.setPolicy(user.json()["Id"], policy) else: - log.debug('user policy was blank') + log.debug("user policy was blank") except: - log.error('Failed to set new user policy') + log.error("Failed to set new user policy") try: configuration = data_store.user_configuration displayprefs = data_store.user_displayprefs if configuration != {} and displayprefs != {}: - if jf.setConfiguration(user.json()['Id'], - configuration): - jf.setDisplayPreferences(user.json()['Id'], - displayprefs) - log.debug('Set homescreen layout.') + if jf.setConfiguration(user.json()["Id"], configuration): + jf.setDisplayPreferences(user.json()["Id"], displayprefs) + log.debug("Set homescreen layout.") else: - log.debug('user configuration and/or ' + - 'displayprefs were blank') + log.debug( + "user configuration and/or " + "displayprefs were blank" + ) except: - log.error('Failed to set new user homescreen layout') - if config.getboolean('password_resets', 'enabled'): - data_store.emails[user.json()['Id']] = data['email'] - log.debug('Email address stored') - log.info('New user created') + log.error("Failed to set new user homescreen layout") + if config.getboolean("password_resets", "enabled"): + data_store.emails[user.json()["Id"]] = data["email"] + log.debug("Email address stored") + log.info("New user created") else: - log.error(f'New user creation failed: {user.status_code}') + log.error(f"New user creation failed: {user.status_code}") return resp(False) else: - log.debug('User password invalid') + log.debug("User password invalid") return jsonify(validation) else: - log.debug('Attempted newUser unauthorized') + log.debug("Attempted newUser unauthorized") return resp(False, code=401) -@app.route('/generateInvite', methods=['POST']) +@app.route("/generateInvite", methods=["POST"]) @auth.login_required def generateInvite(): current_time = datetime.datetime.now() data = request.get_json() - delta = datetime.timedelta(hours=int(data['hours']), - minutes=int(data['minutes'])) + delta = datetime.timedelta(hours=int(data["hours"]), minutes=int(data["minutes"])) invite_code = secrets.token_urlsafe(16) invite = {} - log.debug(f'Creating new invite: {invite_code}') + log.debug(f"Creating new invite: {invite_code}") valid_till = current_time + delta - invite['valid_till'] = valid_till.strftime('%Y-%m-%dT%H:%M:%S.%f') - if 'email' in data and config.getboolean('invite_emails', 'enabled'): - address = data['email'] - invite['email'] = address - log.info(f'Sending invite to {address}') - method = config['email']['method'] - if method == 'mailgun': + invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f") + if "email" in data and config.getboolean("invite_emails", "enabled"): + address = data["email"] + invite["email"] = address + log.info(f"Sending invite to {address}") + method = config["email"]["method"] + if method == "mailgun": from jellyfin_accounts.email import Mailgun + email = Mailgun(address) - elif method == 'smtp': + elif method == "smtp": from jellyfin_accounts.email import Smtp + email = Smtp(address) - email.construct_invite({'expiry': valid_till, - 'code': invite_code}) + email.construct_invite({"expiry": valid_till, "code": invite_code}) response = email.send() if response is False or type(response) != bool: - invite['email'] = f'Failed to send to {address}' + invite["email"] = f"Failed to send to {address}" data_store.invites[invite_code] = invite - log.info(f'New invite created: {invite_code}') + log.info(f"New invite created: {invite_code}") return resp() -@app.route('/getInvites', methods=['GET']) +@app.route("/getInvites", methods=["GET"]) @auth.login_required def getInvites(): - log.debug('Invites requested') + log.debug("Invites requested") current_time = datetime.datetime.now() invites = dict(data_store.invites) for code in invites: checkInvite(code) invites = dict(data_store.invites) - response = {'invites': []} + response = {"invites": []} for code in invites: - expiry = datetime.datetime.strptime(invites[code]['valid_till'], - '%Y-%m-%dT%H:%M:%S.%f') + expiry = datetime.datetime.strptime( + invites[code]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f" + ) valid_for = expiry - current_time - invite = {'code': code, - 'hours': valid_for.seconds//3600, - 'minutes': (valid_for.seconds//60) % 60} - if 'email' in invites[code]: - invite['email'] = invites[code]['email'] - response['invites'].append(invite) + invite = { + "code": code, + "hours": valid_for.seconds // 3600, + "minutes": (valid_for.seconds // 60) % 60, + } + if "email" in invites[code]: + invite["email"] = invites[code]["email"] + response["invites"].append(invite) return jsonify(response) -@app.route('/deleteInvite', methods=['POST']) + +@app.route("/deleteInvite", methods=["POST"]) @auth.login_required def deleteInvite(): - code = request.get_json()['code'] + code = request.get_json()["code"] invites = dict(data_store.invites) if code in invites: del data_store.invites[code] - log.info(f'Invite deleted: {code}') + log.info(f"Invite deleted: {code}") return resp() -@app.route('/getToken') +@app.route("/getToken") @auth.login_required def get_token(): token = g.user.generate_token() - return jsonify({'token': token.decode('ascii')}) + return jsonify({"token": token.decode("ascii")}) -@app.route('/getUsers', methods=['GET']) +@app.route("/getUsers", methods=["GET"]) @auth.login_required def getUsers(): - log.debug('User and email list requested') - response = {'users': []} + log.debug("User and email list requested") + response = {"users": []} users = jf.getUsers(public=False) emails = data_store.emails for user in users: - entry = {'name': user['Name']} - if user['Id'] in emails: - entry['email'] = emails[user['Id']] - response['users'].append(entry) + entry = {"name": user["Name"]} + if user["Id"] in emails: + entry["email"] = emails[user["Id"]] + response["users"].append(entry) return jsonify(response) -@app.route('/modifyUsers', methods=['POST']) +@app.route("/modifyUsers", methods=["POST"]) @auth.login_required def modifyUsers(): data = request.get_json() - log.debug('Email list modification requested') + log.debug("Email list modification requested") for key in data: - uid = jf.getUsers(key, public=False)['Id'] + uid = jf.getUsers(key, public=False)["Id"] data_store.emails[uid] = data[key] log.debug(f'Email for user "{key}" modified') return resp() -@app.route('/setDefaults', methods=['POST']) +@app.route("/setDefaults", methods=["POST"]) @auth.login_required def setDefaults(): data = request.get_json() - username = data['username'] - log.debug(f'Storing default settings from user {username}') + username = data["username"] + log.debug(f"Storing default settings from user {username}") try: - user = jf.getUsers(username=username, - public=False) + user = jf.getUsers(username=username, public=False) except Jellyfin.UserNotFoundError: - log.error(f'Storing defaults failed: Couldn\'t find user {username}') + log.error(f"Storing defaults failed: Couldn't find user {username}") return resp(False) - uid = user['Id'] - policy = user['Policy'] + uid = user["Id"] + policy = user["Policy"] data_store.user_template = policy - if data['homescreen']: - configuration = user['Configuration'] + if data["homescreen"]: + configuration = user["Configuration"] try: displayprefs = jf.getDisplayPreferences(uid) data_store.user_configuration = configuration data_store.user_displayprefs = displayprefs except: - log.error('Storing defaults failed: ' + - 'couldn\'t store homescreen layout') + log.error("Storing defaults failed: " + "couldn't store homescreen layout") return resp(False) return resp() - -import jellyfin_accounts.setup + +import jellyfin_accounts.setup diff --git a/poetry.lock b/poetry.lock index 53296c8..a6024e9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,45 @@ +[[package]] +category = "dev" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +name = "appdirs" +optional = false +python-versions = "*" +version = "1.4.4" + +[[package]] +category = "dev" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.3.0" + +[package.extras] +azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] +dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + +[[package]] +category = "dev" +description = "The uncompromising code formatter." +name = "black" +optional = false +python-versions = ">=3.6" +version = "19.10b0" + +[package.dependencies] +appdirs = "*" +attrs = ">=18.1.0" +click = ">=6.5" +pathspec = ">=0.6,<1" +regex = "*" +toml = ">=0.9.4" +typed-ast = ">=1.4.0" + +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + [[package]] category = "main" description = "Python package for providing Mozilla's CA Bundle." @@ -33,18 +75,6 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "7.1.2" -[[package]] -category = "main" -description = "Updated configparser from Python 3.8 for Python 2.6+." -name = "configparser" -optional = false -python-versions = ">=3.6" -version = "5.0.0" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-black-multipy", "pytest-cov"] - [[package]] category = "main" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." @@ -94,6 +124,14 @@ version = "3.3.0" [package.dependencies] Flask = "*" +[[package]] +category = "dev" +description = "Lightweight in-process concurrent programming" +name = "greenlet" +optional = false +python-versions = "*" +version = "0.4.16" + [[package]] category = "main" description = "Internationalized Domain Names in Applications (IDNA)" @@ -132,6 +170,25 @@ optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" version = "1.1.1" +[[package]] +category = "dev" +description = "MessagePack (de)serializer." +name = "msgpack" +optional = false +python-versions = "*" +version = "1.0.0" + +[[package]] +category = "dev" +description = "Transition packgage for pynvim" +name = "neovim" +optional = false +python-versions = "*" +version = "0.3.1" + +[package.dependencies] +pynvim = ">=0.3.1" + [[package]] category = "main" description = "comprehensive password hashing framework supporting over 30 schemes" @@ -146,6 +203,14 @@ bcrypt = ["bcrypt (>=3.1.0)"] build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.0)"] totp = ["cryptography"] +[[package]] +category = "dev" +description = "Utility library for gitignore style pattern matching of file paths." +name = "pathspec" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.8.0" + [[package]] category = "main" description = "File system general utilities" @@ -162,6 +227,22 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.20" +[[package]] +category = "dev" +description = "Python client to neovim" +name = "pynvim" +optional = false +python-versions = "*" +version = "0.4.1" + +[package.dependencies] +greenlet = "*" +msgpack = ">=0.5.0" + +[package.extras] +pyuv = ["pyuv (>=1.0.0)"] +test = ["pytest (>=3.4.0)"] + [[package]] category = "main" description = "Python wrapper module around the OpenSSL library" @@ -197,6 +278,14 @@ optional = false python-versions = "*" version = "2020.1" +[[package]] +category = "dev" +description = "Alternative regular expression module, to replace re." +name = "regex" +optional = false +python-versions = "*" +version = "2020.6.8" + [[package]] category = "main" description = "Python HTTP for Humans." @@ -223,6 +312,22 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" version = "1.15.0" +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.1" + +[[package]] +category = "dev" +description = "a fork of Python 2 and 3 ast modules with type comment support" +name = "typed-ast" +optional = false +python-versions = "*" +version = "1.4.1" + [[package]] category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -275,10 +380,22 @@ dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx- watchdog = ["watchdog"] [metadata] -content-hash = "fd63698aba27900fe4068b86c7042725a7210e647429dad462966febcf2047b9" +content-hash = "f07c7cafa4edc558a016b9b7742290d7f28579b4e350762d2afbdce21f71796b" python-versions = "^3.6" [metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +attrs = [ + {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, + {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, +] +black = [ + {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, + {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, +] certifi = [ {file = "certifi-2020.4.5.2-py2.py3-none-any.whl", hash = "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc"}, {file = "certifi-2020.4.5.2.tar.gz", hash = "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1"}, @@ -321,10 +438,6 @@ click = [ {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, ] -configparser = [ - {file = "configparser-5.0.0-py3-none-any.whl", hash = "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd"}, - {file = "configparser-5.0.0.tar.gz", hash = "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1"}, -] cryptography = [ {file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"}, {file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"}, @@ -354,6 +467,25 @@ flask-httpauth = [ {file = "Flask-HTTPAuth-3.3.0.tar.gz", hash = "sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9"}, {file = "Flask_HTTPAuth-3.3.0-py2.py3-none-any.whl", hash = "sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72"}, ] +greenlet = [ + {file = "greenlet-0.4.16-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872"}, + {file = "greenlet-0.4.16-cp27-cp27m-win32.whl", hash = "sha256:df7de669cbf21de4b04a3ffc9920bc8426cab4c61365fa84d79bf97401a8bef7"}, + {file = "greenlet-0.4.16-cp27-cp27m-win_amd64.whl", hash = "sha256:1429dc183b36ec972055e13250d96e174491559433eb3061691b446899b87384"}, + {file = "greenlet-0.4.16-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5ea034d040e6ab1d2ae04ab05a3f37dbd719c4dee3804b13903d4cc794b1336e"}, + {file = "greenlet-0.4.16-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c196a5394c56352e21cb7224739c6dd0075b69dd56f758505951d1d8d68cf8a9"}, + {file = "greenlet-0.4.16-cp35-cp35m-win32.whl", hash = "sha256:1000038ba0ea9032948e2156a9c15f5686f36945e8f9906e6b8db49f358e7b52"}, + {file = "greenlet-0.4.16-cp35-cp35m-win_amd64.whl", hash = "sha256:1b805231bfb7b2900a16638c3c8b45c694334c811f84463e52451e00c9412691"}, + {file = "greenlet-0.4.16-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e5db19d4a7d41bbeb3dd89b49fc1bc7e6e515b51bbf32589c618655a0ebe0bf0"}, + {file = "greenlet-0.4.16-cp36-cp36m-win32.whl", hash = "sha256:eac2a3f659d5f41d6bbfb6a97733bc7800ea5e906dc873732e00cebb98cec9e4"}, + {file = "greenlet-0.4.16-cp36-cp36m-win_amd64.whl", hash = "sha256:7eed31f4efc8356e200568ba05ad645525f1fbd8674f1e5be61a493e715e3873"}, + {file = "greenlet-0.4.16-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:682328aa576ec393c1872615bcb877cf32d800d4a2f150e1a5dc7e56644010b1"}, + {file = "greenlet-0.4.16-cp37-cp37m-win32.whl", hash = "sha256:3a35e33902b2e6079949feed7a2dafa5ac6f019da97bd255842bb22de3c11bf5"}, + {file = "greenlet-0.4.16-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b2a984bbfc543d144d88caad6cc7ff4a71be77102014bd617bd88cfb038727"}, + {file = "greenlet-0.4.16-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:d83c1d38658b0f81c282b41238092ed89d8f93c6e342224ab73fb39e16848721"}, + {file = "greenlet-0.4.16-cp38-cp38-win32.whl", hash = "sha256:e695ac8c3efe124d998230b219eb51afb6ef10524a50b3c45109c4b77a8a3a92"}, + {file = "greenlet-0.4.16-cp38-cp38-win_amd64.whl", hash = "sha256:133ba06bad4e5f2f8bf6a0ac434e0fd686df749a86b3478903b92ec3a9c0c90b"}, + {file = "greenlet-0.4.16.tar.gz", hash = "sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c"}, +] idna = [ {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, @@ -401,10 +533,37 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] +msgpack = [ + {file = "msgpack-1.0.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:cec8bf10981ed70998d98431cd814db0ecf3384e6b113366e7f36af71a0fca08"}, + {file = "msgpack-1.0.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aa5c057eab4f40ec47ea6f5a9825846be2ff6bf34102c560bad5cad5a677c5be"}, + {file = "msgpack-1.0.0-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:4233b7f86c1208190c78a525cd3828ca1623359ef48f78a6fea4b91bb995775a"}, + {file = "msgpack-1.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b3758dfd3423e358bbb18a7cccd1c74228dffa7a697e5be6cb9535de625c0dbf"}, + {file = "msgpack-1.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:25b3bc3190f3d9d965b818123b7752c5dfb953f0d774b454fd206c18fe384fb8"}, + {file = "msgpack-1.0.0-cp36-cp36m-win32.whl", hash = "sha256:e7bbdd8e2b277b77782f3ce34734b0dfde6cbe94ddb74de8d733d603c7f9e2b1"}, + {file = "msgpack-1.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5dba6d074fac9b24f29aaf1d2d032306c27f04187651511257e7831733293ec2"}, + {file = "msgpack-1.0.0-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:908944e3f038bca67fcfedb7845c4a257c7749bf9818632586b53bcf06ba4b97"}, + {file = "msgpack-1.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:db685187a415f51d6b937257474ca72199f393dad89534ebbdd7d7a3b000080e"}, + {file = "msgpack-1.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ea41c9219c597f1d2bf6b374d951d310d58684b5de9dc4bd2976db9e1e22c140"}, + {file = "msgpack-1.0.0-cp37-cp37m-win32.whl", hash = "sha256:e35b051077fc2f3ce12e7c6a34cf309680c63a842db3a0616ea6ed25ad20d272"}, + {file = "msgpack-1.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5bea44181fc8e18eed1d0cd76e355073f00ce232ff9653a0ae88cb7d9e643322"}, + {file = "msgpack-1.0.0-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c901e8058dd6653307906c5f157f26ed09eb94a850dddd989621098d347926ab"}, + {file = "msgpack-1.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:271b489499a43af001a2e42f42d876bb98ccaa7e20512ff37ca78c8e12e68f84"}, + {file = "msgpack-1.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7a22c965588baeb07242cb561b63f309db27a07382825fc98aecaf0827c1538e"}, + {file = "msgpack-1.0.0-cp38-cp38-win32.whl", hash = "sha256:002a0d813e1f7b60da599bdf969e632074f9eec1b96cbed8fb0973a63160a408"}, + {file = "msgpack-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:39c54fdebf5fa4dda733369012c59e7d085ebdfe35b6cf648f09d16708f1be5d"}, + {file = "msgpack-1.0.0.tar.gz", hash = "sha256:9534d5cc480d4aff720233411a1f765be90885750b07df772380b34c10ecb5c0"}, +] +neovim = [ + {file = "neovim-0.3.1.tar.gz", hash = "sha256:a6a0e7a5b4433bf4e6ddcbc5c5ff44170be7d84259d002b8e8d8fb4ee78af60f"}, +] passlib = [ {file = "passlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177"}, {file = "passlib-1.7.2.tar.gz", hash = "sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8"}, ] +pathspec = [ + {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, + {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, +] pathtools = [ {file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"}, ] @@ -412,6 +571,9 @@ pycparser = [ {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, ] +pynvim = [ + {file = "pynvim-0.4.1.tar.gz", hash = "sha256:55e918d664654cfa1c9889d3dbe7c63e9f338df5d49471663f78d54c85e84c58"}, +] pyopenssl = [ {file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"}, {file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"}, @@ -424,6 +586,29 @@ pytz = [ {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, ] +regex = [ + {file = "regex-2020.6.8-cp27-cp27m-win32.whl", hash = "sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c"}, + {file = "regex-2020.6.8-cp27-cp27m-win_amd64.whl", hash = "sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938"}, + {file = "regex-2020.6.8-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89"}, + {file = "regex-2020.6.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868"}, + {file = "regex-2020.6.8-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f"}, + {file = "regex-2020.6.8-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded"}, + {file = "regex-2020.6.8-cp36-cp36m-win32.whl", hash = "sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3"}, + {file = "regex-2020.6.8-cp36-cp36m-win_amd64.whl", hash = "sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd"}, + {file = "regex-2020.6.8-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a"}, + {file = "regex-2020.6.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae"}, + {file = "regex-2020.6.8-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a"}, + {file = "regex-2020.6.8-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3"}, + {file = "regex-2020.6.8-cp37-cp37m-win32.whl", hash = "sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9"}, + {file = "regex-2020.6.8-cp37-cp37m-win_amd64.whl", hash = "sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29"}, + {file = "regex-2020.6.8-cp38-cp38-manylinux1_i686.whl", hash = "sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610"}, + {file = "regex-2020.6.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387"}, + {file = "regex-2020.6.8-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910"}, + {file = "regex-2020.6.8-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf"}, + {file = "regex-2020.6.8-cp38-cp38-win32.whl", hash = "sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754"}, + {file = "regex-2020.6.8-cp38-cp38-win_amd64.whl", hash = "sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5"}, + {file = "regex-2020.6.8.tar.gz", hash = "sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac"}, +] requests = [ {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, @@ -432,6 +617,33 @@ six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, +] +typed-ast = [ + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, + {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, + {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, + {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, + {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, +] urllib3 = [ {file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"}, {file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, diff --git a/pyproject.toml b/pyproject.toml index 5f7ae10..7cb0a90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jellyfin-accounts" -version = "0.2.0" +version = "0.2.1" readme = "README.md" description = "A simple account management system for Jellyfin" authors = ["Harvey Tindall "] @@ -28,10 +28,11 @@ passlib = "^1.7.2" pytz = "^2020.1" python-dateutil = "^2.8.1" watchdog = "^0.10.2" -configparser = "^5.0.0" waitress = "^1.4.3" [tool.poetry.dev-dependencies] +neovim = "^0.3.1" +black = "^19.10b0" [tool.poetry.scripts]