Formatted with black

This commit is contained in:
Harvey Tindall 2020-06-21 20:29:53 +01:00
parent 079dff8d9f
commit 24045034c8
12 changed files with 913 additions and 568 deletions

View File

@ -13,192 +13,223 @@ import json
from pathlib import Path from pathlib import Path
from flask import Flask, g from flask import Flask, g
from jellyfin_accounts.data_store import JSONStorage from jellyfin_accounts.data_store import JSONStorage
parser = argparse.ArgumentParser(description="jellyfin-accounts") parser = argparse.ArgumentParser(description="jellyfin-accounts")
parser.add_argument("-c", "--config", parser.add_argument("-c", "--config", help="specifies path to configuration file.")
help="specifies path to configuration file.") parser.add_argument(
parser.add_argument("-d", "--data", "-d",
help=("specifies directory to store data in. " + "--data",
"defaults to ~/.jf-accounts.")) 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("--host", help="address to host web ui on.")
parser.add_argument("-p", "--port", parser.add_argument("-p", "--port", help="port to host web ui on.")
help="port to host web ui on.") parser.add_argument(
parser.add_argument("-g", "--get_defaults", "-g",
help=("tool to grab a JF users " + "--get_defaults",
"policy (access, perms, etc.) and " + help=(
"homescreen layout and " + "tool to grab a JF users "
"output it as json to be used as a user template."), + "policy (access, perms, etc.) and "
action='store_true') + "homescreen layout and "
+ "output it as json to be used as a user template."
),
action="store_true",
)
args, leftovers = parser.parse_known_args() args, leftovers = parser.parse_known_args()
if args.data is not None: if args.data is not None:
data_dir = Path(args.data) data_dir = Path(args.data)
else: 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 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(): if not data_dir.exists():
Path.mkdir(data_dir) 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: if args.config is None:
config_path = data_dir / 'config.ini' config_path = data_dir / "config.ini"
shutil.copy(str(local_dir / 'config-default.ini'), shutil.copy(str(local_dir / "config-default.ini"), str(config_path))
str(config_path))
print("Setup through the web UI, or quit and edit the configuration manually.") print("Setup through the web UI, or quit and edit the configuration manually.")
first_run = True first_run = True
else: else:
config_path = Path(args.config) 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: else:
config_path = data_dir / 'config.ini' config_path = data_dir / "config.ini"
config = configparser.RawConfigParser() config = configparser.RawConfigParser()
config.read(config_path) config.read(config_path)
def create_log(name): def create_log(name):
log = logging.getLogger(name) log = logging.getLogger(name)
handler = logging.StreamHandler(sys.stdout) handler = logging.StreamHandler(sys.stdout)
if config.getboolean('ui', 'debug'): if config.getboolean("ui", "debug"):
log.setLevel(logging.DEBUG) log.setLevel(logging.DEBUG)
handler.setLevel(logging.DEBUG) handler.setLevel(logging.DEBUG)
else: else:
log.setLevel(logging.INFO) log.setLevel(logging.INFO)
handler.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) format = logging.Formatter(fmt)
handler.setFormatter(format) handler.setFormatter(format)
log.addHandler(handler) log.addHandler(handler)
log.propagate = False log.propagate = False
return log return log
log = create_log('main')
web_log = create_log('waitress') log = create_log("main")
web_log = create_log("waitress")
if not first_run: if not first_run:
email_log = create_log('emails') email_log = create_log("emails")
auth_log = create_log('auth') auth_log = create_log("auth")
if args.host is not None: if args.host is not None:
log.debug(f'Using specified host {args.host}') log.debug(f"Using specified host {args.host}")
config['ui']['host'] = args.host config["ui"]["host"] = args.host
if args.port is not None: if args.port is not None:
log.debug(f'Using specified port {args.port}') log.debug(f"Using specified port {args.port}")
config['ui']['port'] = args.port config["ui"]["port"] = args.port
for key in config['files']: for key in config["files"]:
if config['files'][key] == '': if config["files"][key] == "":
if key != 'custom_css': if key != "custom_css":
log.debug(f'Using default {key}') log.debug(f"Using default {key}")
config['files'][key] = str(data_dir / (key + '.json')) config["files"][key] = str(data_dir / (key + ".json"))
for key in ['user_configuration', 'user_displayprefs']: for key in ["user_configuration", "user_displayprefs"]:
if key not in config['files']: if key not in config["files"]:
log.debug(f'Using default {key}') log.debug(f"Using default {key}")
config['files'][key] = str(data_dir / (key + '.json')) 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) temp_invites = json.load(f)
if 'invites' in temp_invites: if "invites" in temp_invites:
new_invites = {} new_invites = {}
log.info('Converting invites.json to new format, temporary.') log.info("Converting invites.json to new format, temporary.")
for el in temp_invites['invites']: for el in temp_invites["invites"]:
i = {'valid_till': el['valid_till']} i = {"valid_till": el["valid_till"]}
if 'email' in el: if "email" in el:
i['email'] = el['email'] i["email"] = el["email"]
new_invites[el['code']] = i new_invites[el["code"]] = i
with open(config['files']['invites'], 'w') as f: with open(config["files"]["invites"], "w") as f:
f.write(json.dumps(new_invites, indent=4, default=str)) f.write(json.dumps(new_invites, indent=4, default=str))
data_store = JSONStorage(config['files']['emails'], data_store = JSONStorage(
config['files']['invites'], config["files"]["emails"],
config['files']['user_template'], config["files"]["invites"],
config['files']['user_displayprefs'], config["files"]["user_template"],
config['files']['user_configuration']) config["files"]["user_displayprefs"],
config["files"]["user_configuration"],
)
def default_css(): def default_css():
css = {} css = {}
css['href'] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" css[
css['integrity'] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" "href"
css['crossorigin'] = "anonymous" ] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
css[
"integrity"
] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
css["crossorigin"] = "anonymous"
return css return css
css = {} css = {}
css = default_css() css = default_css()
if 'custom_css' in config['files']: if "custom_css" in config["files"]:
if config['files']['custom_css'] != '': if config["files"]["custom_css"] != "":
try: try:
shutil.copy(config['files']['custom_css'], shutil.copy(
(local_dir / 'static' / 'bootstrap.css')) config["files"]["custom_css"], (local_dir / "static" / "bootstrap.css")
log.debug('Loaded custom CSS') )
css['href'] = '/bootstrap.css' log.debug("Loaded custom CSS")
css['integrity'] = '' css["href"] = "/bootstrap.css"
css['crossorigin'] = '' css["integrity"] = ""
css["crossorigin"] = ""
except FileNotFoundError: 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 if (
config['password_resets']['email_html'] == ''): "email_html" not in config["password_resets"]
log.debug('Using default password reset email HTML template') or config["password_resets"]["email_html"] == ""
config['password_resets']['email_html'] = str(local_dir / 'email.html') ):
if ('email_text' not in config['password_resets'] or log.debug("Using default password reset email HTML template")
config['password_resets']['email_text'] == ''): config["password_resets"]["email_html"] = str(local_dir / "email.html")
log.debug('Using default password reset email plaintext template') if (
config['password_resets']['email_text'] = str(local_dir / 'email.txt') "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 if (
config['invite_emails']['email_html'] == ''): "email_html" not in config["invite_emails"]
log.debug('Using default invite email HTML template') or config["invite_emails"]["email_html"] == ""
config['invite_emails']['email_html'] = str(local_dir / ):
'invite-email.html') log.debug("Using default invite email HTML template")
if ('email_text' not in config['invite_emails'] or config["invite_emails"]["email_html"] = str(local_dir / "invite-email.html")
config['invite_emails']['email_text'] == ''): if (
log.debug('Using default invite email plaintext template') "email_text" not in config["invite_emails"]
config['invite_emails']['email_text'] = str(local_dir / or config["invite_emails"]["email_text"] == ""
'invite-email.txt') ):
if ('public_server' not in config['jellyfin'] or log.debug("Using default invite email plaintext template")
config['jellyfin']['public_server'] == ''): config["invite_emails"]["email_text"] = str(local_dir / "invite-email.txt")
config['jellyfin']['public_server'] = config['jellyfin']['server'] if (
"public_server" not in config["jellyfin"]
or config["jellyfin"]["public_server"] == ""
):
config["jellyfin"]["public_server"] = config["jellyfin"]["server"]
def main(): def main():
if args.get_defaults: if args.get_defaults:
import json import json
from jellyfin_accounts.jf_api import Jellyfin from jellyfin_accounts.jf_api import Jellyfin
jf = Jellyfin(config['jellyfin']['server'],
config['jellyfin']['client'], jf = Jellyfin(
config['jellyfin']['version'], config["jellyfin"]["server"],
config['jellyfin']['device'], config["jellyfin"]["client"],
config['jellyfin']['device_id']) config["jellyfin"]["version"],
config["jellyfin"]["device"],
config["jellyfin"]["device_id"],
)
print("NOTE: This can now be done through the web ui.") print("NOTE: This can now be done through the web ui.")
print(""" print(
"""
This tool lets you grab various settings from a user, This tool lets you grab various settings from a user,
so that they can be applied every time a new account is so that they can be applied every time a new account is
created. """) created. """
)
print("Step 1: User Policy.") print("Step 1: User Policy.")
print(""" print(
"""
A user policy stores a users permissions (e.g access rights and A user policy stores a users permissions (e.g access rights and
most of the other settings in the 'Profile' and 'Access' tabs most of the other settings in the 'Profile' and 'Access' tabs
of a user). """) of a user). """
)
success = False 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 public = False
while not success: while not success:
choice = input(msg) choice = input(msg)
if choice == 'public': if choice == "public":
public = True public = True
print("Make sure the user is publicly visible!") print("Make sure the user is publicly visible!")
success = True success = True
elif choice == 'all': elif choice == "all":
jf.authenticate(config['jellyfin']['username'], jf.authenticate(
config['jellyfin']['password']) config["jellyfin"]["username"], config["jellyfin"]["password"]
)
public = False public = False
success = True success = True
users = jf.getUsers(public=public) users = jf.getUsers(public=public)
@ -207,69 +238,78 @@ def main():
success = False success = False
while not success: while not success:
try: try:
user_index = int(input(">: "))-1 user_index = int(input(">: ")) - 1
policy = users[user_index]['Policy'] policy = users[user_index]["Policy"]
success = True success = True
except (ValueError, IndexError): except (ValueError, IndexError):
pass pass
data_store.user_template = policy data_store.user_template = policy
print(f'Policy written to "{config["files"]["user_template"]}".') print(f'Policy written to "{config["files"]["user_template"]}".')
print('In future, this policy will be copied to all new users.') print("In future, this policy will be copied to all new users.")
print('Step 2: Homescreen Layout') print("Step 2: Homescreen Layout")
print(""" print(
"""
You may want to customize the default layout of a new user's You may want to customize the default layout of a new user's
home screen. These settings can be applied to an account through 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 success = False
while not success: while not success:
choice = input("Grab the chosen user's homescreen layout? [y/n]: ") choice = input("Grab the chosen user's homescreen layout? [y/n]: ")
if choice.lower() == 'y': if choice.lower() == "y":
user_id = users[user_index]['Id'] user_id = users[user_index]["Id"]
configuration = users[user_index]['Configuration'] configuration = users[user_index]["Configuration"]
display_prefs = jf.getDisplayPreferences(user_id) display_prefs = jf.getDisplayPreferences(user_id)
data_store.user_configuration = configuration 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 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 success = True
elif choice.lower() == 'n': elif choice.lower() == "n":
success = True success = True
else: else:
def signal_handler(sig, frame): def signal_handler(sig, frame):
print('Quitting...') print("Quitting...")
sys.exit(0) sys.exit(0)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
global app global app
app = Flask(__name__, root_path=str(local_dir)) app = Flask(__name__, root_path=str(local_dir))
app.config['DEBUG'] = config.getboolean('ui', 'debug') app.config["DEBUG"] = config.getboolean("ui", "debug")
app.config['SECRET_KEY'] = secrets.token_urlsafe(16) app.config["SECRET_KEY"] = secrets.token_urlsafe(16)
from waitress import serve from waitress import serve
if first_run: if first_run:
import jellyfin_accounts.setup import jellyfin_accounts.setup
host = config['ui']['host']
port = config['ui']['port'] host = config["ui"]["host"]
log.info('Starting web UI for first run setup...') port = config["ui"]["port"]
serve(app, log.info("Starting web UI for first run setup...")
host=host, serve(app, host=host, port=port)
port=port)
else: else:
import jellyfin_accounts.web_api import jellyfin_accounts.web_api
import jellyfin_accounts.web import jellyfin_accounts.web
host = config['ui']['host']
port = config['ui']['port'] host = config["ui"]["host"]
log.info(f'Starting web UI on {host}:{port}') port = config["ui"]["port"]
if config.getboolean('password_resets', 'enabled'): log.info(f"Starting web UI on {host}:{port}")
if config.getboolean("password_resets", "enabled"):
def start_pwr(): def start_pwr():
import jellyfin_accounts.pw_reset import jellyfin_accounts.pw_reset
jellyfin_accounts.pw_reset.start() jellyfin_accounts.pw_reset.start()
pwr = threading.Thread(target=start_pwr, daemon=True) pwr = threading.Thread(target=start_pwr, daemon=True)
log.info('Starting email thread') log.info("Starting email thread")
pwr.start() pwr.start()
serve(app, serve(app, host=host, port=int(port))
host=host,
port=int(port))

View File

@ -1,22 +1,24 @@
import json import json
import datetime import datetime
class JSONFile(dict): class JSONFile(dict):
""" """
Behaves like a dictionary, but automatically Behaves like a dictionary, but automatically
reads and writes to a JSON file (most of the time). reads and writes to a JSON file (most of the time).
""" """
@staticmethod @staticmethod
def readJSON(path): def readJSON(path):
try: try:
with open(path, 'r') as f: with open(path, "r") as f:
return json.load(f) return json.load(f)
except FileNotFoundError: except FileNotFoundError:
return {} return {}
@staticmethod @staticmethod
def writeJSON(path, data): 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)) return f.write(json.dumps(data, indent=4, default=str))
def __init__(self, path, data=None): def __init__(self, path, data=None):
@ -50,12 +52,9 @@ class JSONFile(dict):
class JSONStorage: class JSONStorage:
def __init__(self, def __init__(
emails, self, emails, invites, user_template, user_displayprefs, user_configuration
invites, ):
user_template,
user_displayprefs,
user_configuration):
self.emails = JSONFile(path=emails) self.emails = JSONFile(path=emails)
self.invites = JSONFile(path=invites) self.invites = JSONFile(path=invites)
self.user_template = JSONFile(path=user_template) self.user_template = JSONFile(path=user_template)

View File

@ -12,97 +12,109 @@ from jellyfin_accounts import config
from jellyfin_accounts import email_log as log from jellyfin_accounts import email_log as log
class Email(): class Email:
def __init__(self, address): def __init__(self, address):
self.address = address self.address = address
log.debug(f'{self.address}: Creating email') log.debug(f"{self.address}: Creating email")
self.content = {} self.content = {}
self.from_address = config['email']['address'] self.from_address = config["email"]["address"]
self.from_name = config['email']['from'] self.from_name = config["email"]["from"]
log.debug(( log.debug(
f'{self.address}: Sending from {self.from_address} ' + (
f'({self.from_name})')) f"{self.address}: Sending from {self.from_address} "
+ f"({self.from_name})"
)
)
def pretty_time(self, expiry): def pretty_time(self, expiry):
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
date = expiry.strftime(config['email']['date_format']) date = expiry.strftime(config["email"]["date_format"])
if config.getboolean('email', 'use_24h'): if config.getboolean("email", "use_24h"):
log.debug(f'{self.address}: Using 24h time') log.debug(f"{self.address}: Using 24h time")
time = expiry.strftime('%H:%M') time = expiry.strftime("%H:%M")
else: else:
log.debug(f'{self.address}: Using 12h time') log.debug(f"{self.address}: Using 12h time")
time = expiry.strftime('%-I:%M %p') time = expiry.strftime("%-I:%M %p")
expiry_delta = (expiry - current_time).seconds expiry_delta = (expiry - current_time).seconds
expires_in = {'hours': expiry_delta//3600, expires_in = {
'minutes': (expiry_delta//60) % 60} "hours": expiry_delta // 3600,
if expires_in['hours'] == 0: "minutes": (expiry_delta // 60) % 60,
}
if expires_in["hours"] == 0:
expires_in = f'{str(expires_in["minutes"])}m' expires_in = f'{str(expires_in["minutes"])}m'
else: else:
expires_in = (f'{str(expires_in["hours"])}h ' + expires_in = (
f'{str(expires_in["minutes"])}m') 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} log.debug(f"{self.address}: Expires in {expires_in}")
return {"date": date, "time": time, "expires_in": expires_in}
def construct_invite(self, invite): def construct_invite(self, invite):
self.subject = config['invite_emails']['subject'] self.subject = config["invite_emails"]["subject"]
log.debug(f'{self.address}: Using subject {self.subject}') log.debug(f"{self.address}: Using subject {self.subject}")
log.debug(f'{self.address}: Constructing email content') log.debug(f"{self.address}: Constructing email content")
expiry = invite['expiry'] expiry = invite["expiry"]
expiry.replace(tzinfo=None) expiry.replace(tzinfo=None)
pretty = self.pretty_time(expiry) pretty = self.pretty_time(expiry)
email_message = config['email']['message'] email_message = config["email"]["message"]
invite_link = config['invite_emails']['url_base'] invite_link = config["invite_emails"]["url_base"]
invite_link += '/' + invite['code'] invite_link += "/" + invite["code"]
for key in ['text', 'html']: for key in ["text", "html"]:
sp = Path(config['invite_emails']['email_' + key]) / '..' sp = Path(config["invite_emails"]["email_" + key]) / ".."
sp = str(sp.resolve()) + '/' sp = str(sp.resolve()) + "/"
template_loader = FileSystemLoader(searchpath=sp) template_loader = FileSystemLoader(searchpath=sp)
template_env = Environment(loader=template_loader) 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) template = template_env.get_template(fname)
c = template.render(expiry_date=pretty['date'], c = template.render(
expiry_time=pretty['time'], expiry_date=pretty["date"],
expires_in=pretty['expires_in'], expiry_time=pretty["time"],
expires_in=pretty["expires_in"],
invite_link=invite_link, invite_link=invite_link,
message=email_message) message=email_message,
)
self.content[key] = c self.content[key] = c
log.info(f'{self.address}: {key} constructed') log.info(f"{self.address}: {key} constructed")
def construct_reset(self, reset): def construct_reset(self, reset):
self.subject = config['password_resets']['subject'] self.subject = config["password_resets"]["subject"]
log.debug(f'{self.address}: Using subject {self.subject}') log.debug(f"{self.address}: Using subject {self.subject}")
log.debug(f'{self.address}: Constructing email content') log.debug(f"{self.address}: Constructing email content")
try: try:
expiry = date_parser.parse(reset['ExpirationDate']) expiry = date_parser.parse(reset["ExpirationDate"])
expiry = expiry.replace(tzinfo=None) expiry = expiry.replace(tzinfo=None)
except: except:
log.error(f"{self.address}: Couldn't parse expiry time") log.error(f"{self.address}: Couldn't parse expiry time")
return False return False
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
if expiry >= current_time: if expiry >= current_time:
log.debug(f'{self.address}: Invite valid') log.debug(f"{self.address}: Invite valid")
pretty = self.pretty_time(expiry) pretty = self.pretty_time(expiry)
email_message = config['email']['message'] email_message = config["email"]["message"]
for key in ['text', 'html']: for key in ["text", "html"]:
sp = Path(config['password_resets']['email_' + key]) / '..' sp = Path(config["password_resets"]["email_" + key]) / ".."
sp = str(sp.resolve()) + '/' sp = str(sp.resolve()) + "/"
template_loader = FileSystemLoader(searchpath=sp) template_loader = FileSystemLoader(searchpath=sp)
template_env = Environment(loader=template_loader) 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) template = template_env.get_template(fname)
c = template.render(username=reset['UserName'], c = template.render(
expiry_date=pretty['date'], username=reset["UserName"],
expiry_time=pretty['time'], expiry_date=pretty["date"],
expires_in=pretty['expires_in'], expiry_time=pretty["time"],
pin=reset['Pin'], expires_in=pretty["expires_in"],
message=email_message) pin=reset["Pin"],
message=email_message,
)
self.content[key] = c self.content[key] = c
log.info(f'{self.address}: {key} constructed') log.info(f"{self.address}: {key} constructed")
return True return True
else: else:
err = ((f"{self.address}: " + err = (
"Reset has reportedly already expired. " + f"{self.address}: "
"Ensure timezones are correctly configured.")) + "Reset has reportedly already expired. "
+ "Ensure timezones are correctly configured."
)
log.error(err) log.error(err)
return False return False
@ -110,71 +122,74 @@ class Email():
class Mailgun(Email): class Mailgun(Email):
def __init__(self, address): def __init__(self, address):
super().__init__(address) super().__init__(address)
self.api_url = config['mailgun']['api_url'] self.api_url = config["mailgun"]["api_url"]
self.api_key = config['mailgun']['api_key'] self.api_key = config["mailgun"]["api_key"]
self.from_mg = f'{self.from_name} <{self.from_address}>' self.from_mg = f"{self.from_name} <{self.from_address}>"
def send(self): def send(self):
response = requests.post(self.api_url, response = requests.post(
self.api_url,
auth=("api", self.api_key), auth=("api", self.api_key),
data={"from": self.from_mg, data={
"from": self.from_mg,
"to": [self.address], "to": [self.address],
"subject": self.subject, "subject": self.subject,
"text": self.content['text'], "text": self.content["text"],
"html": self.content['html']}) "html": self.content["html"],
},
)
if response.ok: if response.ok:
log.info(f'{self.address}: Sent via mailgun.') log.info(f"{self.address}: Sent via mailgun.")
return True return True
log.debug(f'{self.address}: Mailgun: {response.status_code}') log.debug(f"{self.address}: Mailgun: {response.status_code}")
return response return response
class Smtp(Email): class Smtp(Email):
def __init__(self, address): def __init__(self, address):
super().__init__(address) super().__init__(address)
self.server = config['smtp']['server'] self.server = config["smtp"]["server"]
self.password = config['smtp']['password'] self.password = config["smtp"]["password"]
try: try:
self.port = int(config['smtp']['port']) self.port = int(config["smtp"]["port"])
except ValueError: except ValueError:
self.port = 465 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): def send(self):
message = MIMEMultipart("alternative") message = MIMEMultipart("alternative")
message["Subject"] = self.subject message["Subject"] = self.subject
message["From"] = self.from_address message["From"] = self.from_address
message["To"] = self.address message["To"] = self.address
text = MIMEText(self.content['text'], 'plain') text = MIMEText(self.content["text"], "plain")
html = MIMEText(self.content['html'], 'html') html = MIMEText(self.content["html"], "html")
message.attach(text) message.attach(text)
message.attach(html) message.attach(html)
try: try:
if config['smtp']['encryption'] == 'ssl_tls': if config["smtp"]["encryption"] == "ssl_tls":
self.context = ssl.create_default_context() self.context = ssl.create_default_context()
with smtplib.SMTP_SSL(self.server, with smtplib.SMTP_SSL(
self.port, self.server, self.port, context=self.context
context=self.context) as server: ) as server:
server.ehlo() server.ehlo()
server.login(self.from_address, self.password) server.login(self.from_address, self.password)
server.sendmail(self.from_address, server.sendmail(
self.address, self.from_address, self.address, message.as_string()
message.as_string()) )
log.info(f'{self.address}: Sent via smtp (ssl/tls)') log.info(f"{self.address}: Sent via smtp (ssl/tls)")
return True return True
elif config['smtp']['encryption'] == 'starttls': elif config["smtp"]["encryption"] == "starttls":
with smtplib.SMTP(self.server, with smtplib.SMTP(self.server, self.port) as server:
self.port) as server:
server.ehlo() server.ehlo()
server.starttls() server.starttls()
server.login(self.from_address, self.password) server.login(self.from_address, self.password)
server.sendmail(self.from_address, server.sendmail(
self.address, self.from_address, self.address, message.as_string()
message.as_string()) )
log.info(f'{self.address}: Sent via smtp (starttls)') log.info(f"{self.address}: Sent via smtp (starttls)")
return True return True
except Exception as e: 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__ err += type(e).__name__
log.error(err) log.error(err)
try: try:

View File

@ -2,35 +2,48 @@
import requests import requests
import time import time
class Error(Exception): class Error(Exception):
pass pass
class Jellyfin: class Jellyfin:
""" """
Basic Jellyfin API client, providing account related function only. Basic Jellyfin API client, providing account related function only.
""" """
class UserExistsError(Error): class UserExistsError(Error):
""" """
Thrown if a user already exists with the same name Thrown if a user already exists with the same name
when creating an account. when creating an account.
""" """
pass pass
class UserNotFoundError(Error): class UserNotFoundError(Error):
"""Thrown if account with specified user ID/name does not exist.""" """Thrown if account with specified user ID/name does not exist."""
pass pass
class AuthenticationError(Error): class AuthenticationError(Error):
"""Thrown if authentication with Jellyfin fails.""" """Thrown if authentication with Jellyfin fails."""
pass pass
class AuthenticationRequiredError(Error): class AuthenticationRequiredError(Error):
""" """
Thrown if privileged action is attempted without authentication. Thrown if privileged action is attempted without authentication.
""" """
pass pass
class UnknownError(Error): class UnknownError(Error):
""" """
Thrown if i've been too lazy to figure out an error's meaning. Thrown if i've been too lazy to figure out an error's meaning.
""" """
pass pass
def __init__(self, server, client, version, device, deviceId): def __init__(self, server, client, version, device, deviceId):
""" """
Initializes the Jellyfin object. All parameters except server Initializes the Jellyfin object. All parameters except server
@ -64,11 +77,10 @@ class Jellyfin:
"Accept-Charset": "UTF-8,*", "Accept-Charset": "UTF-8,*",
"Accept-encoding": "gzip", "Accept-encoding": "gzip",
"User-Agent": self.useragent, "User-Agent": self.useragent,
"X-Emby-Authorization": self.auth "X-Emby-Authorization": self.auth,
} }
def getUsers(self, username: str = "all",
userId: str = "all", def getUsers(self, username: str = "all", userId: str = "all", public: bool = True):
public: bool = True):
""" """
Returns details on user(s), such as ID, Name, Policy. Returns details on user(s), such as ID, Name, Policy.
@ -81,19 +93,20 @@ class Jellyfin:
""" """
if public is True: if public is True:
if (time.time() - self.userCachePublicAge) >= self.timeout: 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.userCachePublic = response
self.userCachePublicAge = time.time() self.userCachePublicAge = time.time()
else: else:
response = self.userCachePublic response = self.userCachePublic
elif (public is False and elif (
hasattr(self, 'username') and public is False and hasattr(self, "username") and hasattr(self, "password")
hasattr(self, 'password')): ):
if (time.time() - self.userCacheAge) >= self.timeout: if (time.time() - self.userCacheAge) >= self.timeout:
response = requests.get(self.server+"/emby/Users", response = requests.get(
self.server + "/emby/Users",
headers=self.header, headers=self.header,
params={'Username': self.username, params={"Username": self.username, "Pw": self.password},
'Pw': self.password}) )
if response.status_code == 200: if response.status_code == 200:
response = response.json() response = response.json()
self.userCache = response self.userCache = response
@ -113,7 +126,7 @@ class Jellyfin:
elif id == "all": elif id == "all":
match = False match = False
for user in response: for user in response:
if user['Name'] == username: if user["Name"] == username:
match = True match = True
return user return user
if not match: if not match:
@ -121,7 +134,7 @@ class Jellyfin:
else: else:
match = False match = False
for user in response: for user in response:
if user['Id'] == id: if user["Id"] == id:
match = True match = True
return user return user
if not match: if not match:
@ -136,24 +149,26 @@ class Jellyfin:
""" """
self.username = username self.username = username
self.password = password self.password = password
response = requests.post(self.server+"/emby/Users/AuthenticateByName", response = requests.post(
self.server + "/emby/Users/AuthenticateByName",
headers=self.header, headers=self.header,
params={'Username': self.username, params={"Username": self.username, "Pw": self.password},
'Pw': self.password}) )
if response.status_code == 200: if response.status_code == 200:
json = response.json() json = response.json()
self.userId = json['User']['Id'] self.userId = json["User"]["Id"]
self.accessToken = json['AccessToken'] self.accessToken = json["AccessToken"]
self.auth = "MediaBrowser " self.auth = "MediaBrowser "
self.auth += f"Client={self.client}, " self.auth += f"Client={self.client}, "
self.auth += f"Device={self.device}, " self.auth += f"Device={self.device}, "
self.auth += f"DeviceId={self.deviceId}, " self.auth += f"DeviceId={self.deviceId}, "
self.auth += f"Version={self.version}" self.auth += f"Version={self.version}"
self.auth += f", Token={self.accessToken}" self.auth += f", Token={self.accessToken}"
self.header['X-Emby-Authorization'] = self.auth self.header["X-Emby-Authorization"] = self.auth
return True return True
else: else:
raise self.AuthenticationError raise self.AuthenticationError
def setPolicy(self, userId: str, policy: dict): def setPolicy(self, userId: str, policy: dict):
""" """
Sets a user's policy (Admin rights, Library Access, etc.) by user ID. Sets a user's policy (Admin rights, Library Access, etc.) by user ID.
@ -161,55 +176,64 @@ class Jellyfin:
:param userId: ID of the user to modify. :param userId: ID of the user to modify.
:param policy: User policy in dictionary form. :param policy: User policy in dictionary form.
""" """
return requests.post(self.server+"/Users/"+userId+"/Policy", return requests.post(
self.server + "/Users/" + userId + "/Policy",
headers=self.header, headers=self.header,
params=policy) params=policy,
)
def newUser(self, username: str, password: str): def newUser(self, username: str, password: str):
for user in self.getUsers(): for user in self.getUsers():
if user['Name'] == username: if user["Name"] == username:
raise self.UserExistsError raise self.UserExistsError
response = requests.post(self.server+"/emby/Users/New", response = requests.post(
self.server + "/emby/Users/New",
headers=self.header, headers=self.header,
params={'Name': username, params={"Name": username, "Password": password},
'Password': password}) )
if response.status_code == 401: 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) self.authenticate(self.username, self.password)
return self.newUser(username, password) return self.newUser(username, password)
else: else:
raise self.AuthenticationRequiredError raise self.AuthenticationRequiredError
return response return response
def getViewOrder(self, userId: str, public: bool = True): def getViewOrder(self, userId: str, public: bool = True):
if not public: if not public:
param = '?IncludeHidden=true' param = "?IncludeHidden=true"
else: else:
param = '' param = ""
views = requests.get(self.server+"/Users/"+userId+"/Views"+param, views = requests.get(
headers=self.header).json()['Items'] self.server + "/Users/" + userId + "/Views" + param, headers=self.header
).json()["Items"]
orderedViews = [] orderedViews = []
for library in views: for library in views:
orderedViews.append(library['Id']) orderedViews.append(library["Id"])
return orderedViews return orderedViews
def setConfiguration(self, userId: str, configuration: dict): def setConfiguration(self, userId: str, configuration: dict):
""" """
Sets a user's configuration (Settings the user can change themselves). Sets a user's configuration (Settings the user can change themselves).
:param userId: ID of the user to modify. :param userId: ID of the user to modify.
:param configuration: Configuration to write in dictionary form. :param configuration: Configuration to write in dictionary form.
""" """
resp = requests.post(self.server+"/Users/"+userId+"/Configuration", resp = requests.post(
self.server + "/Users/" + userId + "/Configuration",
headers=self.header, headers=self.header,
params=configuration) params=configuration,
if (resp.status_code == 200 or )
resp.status_code == 204): if resp.status_code == 200 or resp.status_code == 204:
return True return True
elif resp.status_code == 401: 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) self.authenticate(self.username, self.password)
return self.setConfiguration(userId, configuration) return self.setConfiguration(userId, configuration)
else: else:
raise self.AuthenticationRequiredError raise self.AuthenticationRequiredError
else: else:
raise self.UnknownError raise self.UnknownError
def getConfiguration(self, username: str = "all", userId: str = "all"): def getConfiguration(self, username: str = "all", userId: str = "all"):
""" """
Gets a user's Configuration. This can also be found in getUsers if Gets a user's Configuration. This can also be found in getUsers if
@ -217,27 +241,36 @@ class Jellyfin:
:param username: The user's username. :param username: The user's username.
:param userId: The user's ID. :param userId: The user's ID.
""" """
return self.getUsers(username=username, return self.getUsers(username=username, userId=userId, public=False)[
userId=userId, "Configuration"
public=False)['Configuration'] ]
def getDisplayPreferences(self, userId: str): def getDisplayPreferences(self, userId: str):
""" """
Gets a user's Display Preferences (Home layout). Gets a user's Display Preferences (Home layout).
:param userId: The user's ID. :param userId: The user's ID.
""" """
resp = requests.get((self.server+"/DisplayPreferences/usersettings" + resp = requests.get(
"?userId="+userId+"&client=emby"), (
headers=self.header) self.server
+ "/DisplayPreferences/usersettings"
+ "?userId="
+ userId
+ "&client=emby"
),
headers=self.header,
)
if resp.status_code == 200: if resp.status_code == 200:
return resp.json() return resp.json()
elif resp.status_code == 401: 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) self.authenticate(self.username, self.password)
return self.getDisplayPreferences(userId) return self.getDisplayPreferences(userId)
else: else:
raise self.AuthenticationRequiredError raise self.AuthenticationRequiredError
else: else:
raise self.UnknownError raise self.UnknownError
def setDisplayPreferences(self, userId: str, preferences: dict): def setDisplayPreferences(self, userId: str, preferences: dict):
""" """
Sets a user's Display Preferences (Home layout). Sets a user's Display Preferences (Home layout).
@ -245,16 +278,22 @@ class Jellyfin:
:param preferences: The preferences to set. :param preferences: The preferences to set.
""" """
tempheader = self.header tempheader = self.header
tempheader['Content-type'] = 'application/json' tempheader["Content-type"] = "application/json"
resp = requests.post((self.server+"/DisplayPreferences/usersettings" + resp = requests.post(
"?userId="+userId+"&client=emby"), (
self.server
+ "/DisplayPreferences/usersettings"
+ "?userId="
+ userId
+ "&client=emby"
),
headers=tempheader, headers=tempheader,
json=preferences) json=preferences,
if (resp.status_code == 200 or )
resp.status_code == 204): if resp.status_code == 200 or resp.status_code == 204:
return True return True
elif resp.status_code == 401: 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) self.authenticate(self.username, self.password)
return self.setDisplayPreferences(userId, preferences) return self.setDisplayPreferences(userId, preferences)
else: else:

View File

@ -1,9 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# from flask import g
from flask_httpauth import HTTPBasicAuth from flask_httpauth import HTTPBasicAuth
from itsdangerous import (TimedJSONWebSignatureSerializer from itsdangerous import (
as Serializer, BadSignature, SignatureExpired) TimedJSONWebSignatureSerializer as Serializer,
BadSignature,
SignatureExpired,
)
from passlib.apps import custom_app_context as pwd_context from passlib.apps import custom_app_context as pwd_context
import uuid import uuid
from jellyfin_accounts import config, app, g 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.jf_api import Jellyfin
from jellyfin_accounts.web_api import jf from jellyfin_accounts.web_api import jf
auth_jf = Jellyfin(config['jellyfin']['server'], auth_jf = Jellyfin(
config['jellyfin']['client'], config["jellyfin"]["server"],
config['jellyfin']['version'], config["jellyfin"]["client"],
config['jellyfin']['device'], config["jellyfin"]["version"],
config['jellyfin']['device_id'] + '_authClient') config["jellyfin"]["device"],
config["jellyfin"]["device_id"] + "_authClient",
)
class Account():
class Account:
def __init__(self, username=None, password=None): def __init__(self, username=None, password=None):
self.username = username self.username = username
if password is not None: if password is not None:
@ -25,10 +30,12 @@ class Account():
self.id = str(uuid.uuid4()) self.id = str(uuid.uuid4())
self.jf = False self.jf = False
elif username is not None: elif username is not None:
jf.authenticate(config['jellyfin']['username'], jf.authenticate(
config['jellyfin']['password']) config["jellyfin"]["username"], config["jellyfin"]["password"]
self.id = jf.getUsers(self.username, public=False)['Id'] )
self.id = jf.getUsers(self.username, public=False)["Id"]
self.jf = True self.jf = True
def verify_password(self, password): def verify_password(self, password):
if not self.jf: if not self.jf:
return pwd_context.verify(password, self.password_hash) return pwd_context.verify(password, self.password_hash)
@ -37,59 +44,60 @@ class Account():
return auth_jf.authenticate(self.username, password) return auth_jf.authenticate(self.username, password)
except Jellyfin.AuthenticationError: except Jellyfin.AuthenticationError:
return False return False
def generate_token(self, expiration=1200): 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) log.debug(self.id)
return s.dumps({ 'id': self.id }) return s.dumps({"id": self.id})
@staticmethod @staticmethod
def verify_token(token, accounts): def verify_token(token, accounts):
log.debug(f'verifying token {token}') log.debug(f"verifying token {token}")
s = Serializer(app.config['SECRET_KEY']) s = Serializer(app.config["SECRET_KEY"])
try: try:
data = s.loads(token) data = s.loads(token)
except SignatureExpired: except SignatureExpired:
return None return None
except BadSignature: except BadSignature:
return None return None
if config.getboolean('ui', 'jellyfin_login'): if config.getboolean("ui", "jellyfin_login"):
for account in accounts: for account in accounts:
if data['id'] == accounts[account].id: if data["id"] == accounts[account].id:
return account return account
else: else:
return accounts['adminAccount'] return accounts["adminAccount"]
auth = HTTPBasicAuth() auth = HTTPBasicAuth()
accounts = {} accounts = {}
if config.getboolean('ui', 'jellyfin_login'): if config.getboolean("ui", "jellyfin_login"):
log.debug('Using jellyfin for admin authentication') log.debug("Using jellyfin for admin authentication")
else: else:
log.debug('Using configured login details for admin authentication') log.debug("Using configured login details for admin authentication")
accounts['adminAccount'] = Account(config['ui']['username'], accounts["adminAccount"] = Account(
config['ui']['password']) config["ui"]["username"], config["ui"]["password"]
)
@auth.verify_password @auth.verify_password
def verify_password(username, password): def verify_password(username, password):
user = None user = None
verified = False verified = False
log.debug('Verifying auth') log.debug("Verifying auth")
if config.getboolean('ui', 'jellyfin_login'): if config.getboolean("ui", "jellyfin_login"):
try: try:
jf_user = jf.getUsers(username, public=False) jf_user = jf.getUsers(username, public=False)
id = jf_user['Id'] id = jf_user["Id"]
user = accounts[id] user = accounts[id]
except KeyError: except KeyError:
if config.getboolean('ui', 'admin_only'): if config.getboolean("ui", "admin_only"):
if jf_user['Policy']['IsAdministrator']: if jf_user["Policy"]["IsAdministrator"]:
user = Account(username) user = Account(username)
accounts[id] = user accounts[id] = user
else: else:
log.debug(f'User {username} not admin.') log.debug(f"User {username} not admin.")
return False return False
else: else:
user = Account(username) user = Account(username)
@ -99,10 +107,10 @@ def verify_password(username, password):
if user: if user:
verified = True verified = True
if not user: if not user:
log.debug(f'User {username} not found on Jellyfin') log.debug(f"User {username} not found on Jellyfin")
return False return False
else: else:
user = accounts['adminAccount'] user = accounts["adminAccount"]
verified = Account().verify_token(username, accounts) verified = Account().verify_token(username, accounts)
if not verified: if not verified:
if username == user.username and user.verify_password(password): if username == user.username and user.verify_password(password):
@ -115,6 +123,3 @@ def verify_password(username, password):
g.user = user g.user = user
log.debug("HTTPAuth Allowed") log.debug("HTTPAuth Allowed")
return True return True

View File

@ -8,7 +8,6 @@ from jellyfin_accounts import config, data_store
from jellyfin_accounts import email_log as log from jellyfin_accounts import email_log as log
class Watcher: class Watcher:
def __init__(self, dir): def __init__(self, dir):
self.observer = Observer() self.observer = Observer()
@ -20,13 +19,13 @@ class Watcher:
try: try:
self.observer.start() self.observer.start()
except NotADirectoryError: except NotADirectoryError:
log.error(f'Directory {self.dir} does not exist') log.error(f"Directory {self.dir} does not exist")
try: try:
while True: while True:
time.sleep(5) time.sleep(5)
except: except:
self.observer.stop() self.observer.stop()
log.info('Watchdog stopped') log.info("Watchdog stopped")
class Handler(FileSystemEventHandler): class Handler(FileSystemEventHandler):
@ -34,33 +33,35 @@ class Handler(FileSystemEventHandler):
def on_any_event(event): def on_any_event(event):
if event.is_directory: if event.is_directory:
return None return None
elif (event.event_type == 'modified' and elif event.event_type == "modified" and "passwordreset" in event.src_path:
'passwordreset' in event.src_path): log.debug(f"Password reset file: {event.src_path}")
log.debug(f'Password reset file: {event.src_path}')
time.sleep(1) time.sleep(1)
with open(event.src_path, 'r') as f: with open(event.src_path, "r") as f:
reset = json.load(f) reset = json.load(f)
log.info(f'New password reset for {reset["UserName"]}') log.info(f'New password reset for {reset["UserName"]}')
try: try:
id = jf.getUsers(reset['UserName'], public=False)['Id'] id = jf.getUsers(reset["UserName"], public=False)["Id"]
address = data_store.emails[id] address = data_store.emails[id]
if address != '': if address != "":
method = config['email']['method'] method = config["email"]["method"]
if method == 'mailgun': if method == "mailgun":
email = Mailgun(address) email = Mailgun(address)
elif method == 'smtp': elif method == "smtp":
email = Smtp(address) email = Smtp(address)
if email.construct_reset(reset): if email.construct_reset(reset):
email.send() email.send()
else: else:
raise IndexError raise IndexError
except (FileNotFoundError, except (
FileNotFoundError,
json.decoder.JSONDecodeError, json.decoder.JSONDecodeError,
IndexError) as e: IndexError,
err = f'{address}: Failed: ' + type(e).__name__ ) as e:
err = f"{address}: Failed: " + type(e).__name__
log.error(err) log.error(err)
def start(): def start():
log.info(f'Monitoring {config["password_resets"]["watch_directory"]}') 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() w.run()

View File

@ -7,34 +7,36 @@ from jellyfin_accounts.web_api import resp
import os import os
if first_run: if first_run:
def tempJF(server): def tempJF(server):
return Jellyfin(server, return Jellyfin(
config['jellyfin']['client'], server,
config['jellyfin']['version'], config["jellyfin"]["client"],
config['jellyfin']['device'] + '_temp', config["jellyfin"]["version"],
config['jellyfin']['device_id'] + '_temp') config["jellyfin"]["device"] + "_temp",
config["jellyfin"]["device_id"] + "_temp",
)
@app.errorhandler(404) @app.errorhandler(404)
def page_not_found(e): 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(): def setup():
return render_template('setup.html') return render_template("setup.html")
@app.route('/<path:path>') @app.route("/<path:path>")
def static_proxy(path): def static_proxy(path):
if 'html' not in path: if "html" not in path:
return app.send_static_file(path) return app.send_static_file(path)
else: 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(): def modifyConfig():
log.info('Config modification requested') log.info("Config modification requested")
data = request.get_json() data = request.get_json()
temp_config = RawConfigParser(comment_prefixes='/', temp_config = RawConfigParser(comment_prefixes="/", allow_no_value=True)
allow_no_value=True)
temp_config.read(config_path) temp_config.read(config_path)
for section in data: for section in data:
if section in temp_config: if section in temp_config:
@ -42,24 +44,23 @@ if first_run:
if item in temp_config[section]: if item in temp_config[section]:
temp_config[section][item] = data[section][item] temp_config[section][item] = data[section][item]
data[section][item] = True data[section][item] = True
log.debug(f'{section}/{item} modified') log.debug(f"{section}/{item} modified")
else: else:
data[section][item] = False data[section][item] = False
log.debug(f'{section}/{item} does not exist in config') log.debug(f"{section}/{item} does not exist in config")
with open(config_path, 'w') as config_file: with open(config_path, "w") as config_file:
temp_config.write(config_file) temp_config.write(config_file)
log.debug('Config written') log.debug("Config written")
# ugly exit, sorry # ugly exit, sorry
os._exit(1) os._exit(1)
return resp() return resp()
@app.route('/testJF', methods=['GET', 'POST']) @app.route("/testJF", methods=["GET", "POST"])
def testJF(): def testJF():
data = request.get_json() data = request.get_json()
tempjf = tempJF(data['jfHost']) tempjf = tempJF(data["jfHost"])
try: try:
tempjf.authenticate(data['jfUser'], tempjf.authenticate(data["jfUser"], data["jfPassword"])
data['jfPassword'])
tempjf.getUsers(public=False) tempjf.getUsers(public=False)
return resp() return resp()
except: except:

View File

@ -3,33 +3,39 @@ specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')',
class PasswordValidator: class PasswordValidator:
def __init__(self, min_length, upper, lower, number, special): def __init__(self, min_length, upper, lower, number, special):
self.criteria = {'characters': int(min_length), self.criteria = {
'uppercase characters': int(upper), "characters": int(min_length),
'lowercase characters': int(lower), "uppercase characters": int(upper),
'numbers': int(number), "lowercase characters": int(lower),
'special characters': int(special)} "numbers": int(number),
"special characters": int(special),
}
def validate(self, password): def validate(self, password):
count = {'characters': 0, count = {
'uppercase characters': 0, "characters": 0,
'lowercase characters': 0, "uppercase characters": 0,
'numbers': 0, "lowercase characters": 0,
'special characters': 0} "numbers": 0,
"special characters": 0,
}
for c in password: for c in password:
count['characters'] += 1 count["characters"] += 1
if c.isupper(): if c.isupper():
count['uppercase characters'] += 1 count["uppercase characters"] += 1
elif c.islower(): elif c.islower():
count['lowercase characters'] += 1 count["lowercase characters"] += 1
elif c.isnumeric(): elif c.isnumeric():
count['numbers'] += 1 count["numbers"] += 1
elif c in specials: elif c in specials:
count['special characters'] += 1 count["special characters"] += 1
for criterion in count: for criterion in count:
if count[criterion] < self.criteria[criterion]: if count[criterion] < self.criteria[criterion]:
count[criterion] = False count[criterion] = False
else: else:
count[criterion] = True count[criterion] = True
return count return count
def getCriteria(self): def getCriteria(self):
lines = {} lines = {}
for criterion in self.criteria: for criterion in self.criteria:

View File

@ -8,63 +8,76 @@ from jellyfin_accounts.web_api import checkInvite, validator
@app.errorhandler(404) @app.errorhandler(404)
def page_not_found(e): def page_not_found(e):
return render_template('404.html', return (
css_href=css['href'], render_template(
css_integrity=css['integrity'], "404.html",
css_crossorigin=css['crossorigin'], css_href=css["href"],
contactMessage=config['ui']['contact_message']), 404 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(): def admin():
# return app.send_static_file('admin.html') # return app.send_static_file('admin.html')
return render_template('admin.html', return render_template(
css_href=css['href'], "admin.html",
css_integrity=css['integrity'], css_href=css["href"],
css_crossorigin=css['crossorigin'], css_integrity=css["integrity"],
contactMessage='', css_crossorigin=css["crossorigin"],
email_enabled=config.getboolean( contactMessage="",
'invite_emails', 'enabled')) email_enabled=config.getboolean("invite_emails", "enabled"),
)
@app.route('/<path:path>') @app.route("/<path:path>")
def static_proxy(path): def static_proxy(path):
if 'html' not in path: if "html" not in path:
return app.send_static_file(path) return app.send_static_file(path)
return render_template('404.html', return (
css_href=css['href'], render_template(
css_integrity=css['integrity'], "404.html",
css_crossorigin=css['crossorigin'], css_href=css["href"],
contactMessage=config['ui']['contact_message']), 404 css_integrity=css["integrity"],
css_crossorigin=css["crossorigin"],
contactMessage=config["ui"]["contact_message"],
),
404,
)
@app.route('/invite/<path:path>') @app.route("/invite/<path:path>")
def inviteProxy(path): def inviteProxy(path):
if checkInvite(path): if checkInvite(path):
log.info(f'Invite {path} used to request form') log.info(f"Invite {path} used to request form")
try: try:
email = data_store.invites[path]['email'] email = data_store.invites[path]["email"]
except KeyError: except KeyError:
email = '' email = ""
return render_template('form.html', return render_template(
css_href=css['href'], "form.html",
css_integrity=css['integrity'], css_href=css["href"],
css_crossorigin=css['crossorigin'], css_integrity=css["integrity"],
contactMessage=config['ui']['contact_message'], css_crossorigin=css["crossorigin"],
helpMessage=config['ui']['help_message'], contactMessage=config["ui"]["contact_message"],
successMessage=config['ui']['success_message'], helpMessage=config["ui"]["help_message"],
jfLink=config['jellyfin']['public_server'], successMessage=config["ui"]["success_message"],
validate=config.getboolean( jfLink=config["jellyfin"]["public_server"],
'password_validation', validate=config.getboolean("password_validation", "enabled"),
'enabled'),
requirements=validator.getCriteria(), requirements=validator.getCriteria(),
email=email) email=email,
elif 'admin.html' not in path and 'admin.html' not in path: )
elif "admin.html" not in path and "admin.html" not in path:
return app.send_static_file(path) return app.send_static_file(path)
else: else:
log.debug('Attempted use of invalid invite') log.debug("Attempted use of invalid invite")
return render_template('invalidCode.html', return render_template(
css_href=css['href'], "invalidCode.html",
css_integrity=css['integrity'], css_href=css["href"],
css_crossorigin=css['crossorigin'], css_integrity=css["integrity"],
contactMessage=config['ui']['contact_message']) css_crossorigin=css["crossorigin"],
contactMessage=config["ui"]["contact_message"],
)

View File

@ -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 import web_log as log
from jellyfin_accounts.validate_password import PasswordValidator from jellyfin_accounts.validate_password import PasswordValidator
def resp(success=True, code=500): def resp(success=True, code=500):
if success: if success:
r = jsonify({'success': True}) r = jsonify({"success": True})
if code == 500: if code == 500:
r.status_code = 200 r.status_code = 200
else: else:
r.status_code = code r.status_code = code
else: else:
r = jsonify({'success': False}) r = jsonify({"success": False})
r.status_code = code r.status_code = code
return r return r
def checkInvite(code, delete=False): def checkInvite(code, delete=False):
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
invites = dict(data_store.invites) invites = dict(data_store.invites)
match = False match = False
for invite in invites: for invite in invites:
expiry = datetime.datetime.strptime(invites[invite]['valid_till'], expiry = datetime.datetime.strptime(
'%Y-%m-%dT%H:%M:%S.%f') invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
)
if current_time >= expiry: 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] del data_store.invites[invite]
elif invite == code: elif invite == code:
match = True match = True
@ -36,34 +39,37 @@ def checkInvite(code, delete=False):
del data_store.invites[code] del data_store.invites[code]
return match return match
jf = Jellyfin(config['jellyfin']['server'],
config['jellyfin']['client'], jf = Jellyfin(
config['jellyfin']['version'], config["jellyfin"]["server"],
config['jellyfin']['device'], config["jellyfin"]["client"],
config['jellyfin']['device_id']) config["jellyfin"]["version"],
config["jellyfin"]["device"],
config["jellyfin"]["device_id"],
)
from jellyfin_accounts.login import auth from jellyfin_accounts.login import auth
jf_address = config['jellyfin']['server'] jf_address = config["jellyfin"]["server"]
success = False success = False
for i in range(3): for i in range(3):
try: try:
jf.authenticate(config['jellyfin']['username'], jf.authenticate(config["jellyfin"]["username"], config["jellyfin"]["password"])
config['jellyfin']['password'])
success = True success = True
log.info(f'Successfully authenticated with {jf_address}') log.info(f"Successfully authenticated with {jf_address}")
break break
except Jellyfin.AuthenticationError: 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) time.sleep(5)
if not success: if not success:
log.error('Could not authenticate after 3 tries.') log.error("Could not authenticate after 3 tries.")
exit() exit()
def switchToIds(): def switchToIds():
try: try:
with open(config['files']['emails'], 'r') as f: with open(config["files"]["emails"], "r") as f:
emails = json.load(f) emails = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError): except (FileNotFoundError, json.decoder.JSONDecodeError):
emails = {} emails = {}
@ -72,220 +78,227 @@ def switchToIds():
match = False match = False
for key in emails: for key in emails:
for user in users: for user in users:
if user['Name'] == key: if user["Name"] == key:
match = True match = True
new_emails[user['Id']] = emails[key] new_emails[user["Id"]] = emails[key]
elif user['Id'] == key: elif user["Id"] == key:
new_emails[user['Id']] = emails[key] new_emails[user["Id"]] = emails[key]
if match: if match:
from pathlib import Path from pathlib import Path
email_file = Path(config['files']['emails']).name
log.info((f'{email_file} modified to use userID instead of ' + email_file = Path(config["files"]["emails"]).name
'usernames. These will be used in future.')) log.info(
(
f"{email_file} modified to use userID instead of "
+ "usernames. These will be used in future."
)
)
emails = new_emails 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)) f.write(json.dumps(emails, indent=4))
# Temporary, switches emails.json over from using Usernames to User IDs. # Temporary, switches emails.json over from using Usernames to User IDs.
switchToIds() switchToIds()
if config.getboolean('password_validation', 'enabled'): if config.getboolean("password_validation", "enabled"):
validator = PasswordValidator(config['password_validation']['min_length'], validator = PasswordValidator(
config['password_validation']['upper'], config["password_validation"]["min_length"],
config['password_validation']['lower'], config["password_validation"]["upper"],
config['password_validation']['number'], config["password_validation"]["lower"],
config['password_validation']['special']) config["password_validation"]["number"],
config["password_validation"]["special"],
)
else: else:
validator = PasswordValidator(0, 0, 0, 0, 0) validator = PasswordValidator(0, 0, 0, 0, 0)
@app.route('/newUser', methods=['POST']) @app.route("/newUser", methods=["POST"])
def newUser(): def newUser():
data = request.get_json() data = request.get_json()
log.debug('Attempted newUser') log.debug("Attempted newUser")
if checkInvite(data['code']): if checkInvite(data["code"]):
validation = validator.validate(data['password']) validation = validator.validate(data["password"])
valid = True valid = True
for criterion in validation: for criterion in validation:
if validation[criterion] is False: if validation[criterion] is False:
valid = False valid = False
if valid: if valid:
log.debug('User password valid') log.debug("User password valid")
try: try:
user = jf.newUser(data['username'], user = jf.newUser(data["username"], data["password"])
data['password'])
except Jellyfin.UserExistsError: except Jellyfin.UserExistsError:
error = f'User already exists named {data["username"]}' error = f'User already exists named {data["username"]}'
log.debug(error) log.debug(error)
return jsonify({'error': error}) return jsonify({"error": error})
except: except:
return jsonify({'error': 'Unknown error'}) return jsonify({"error": "Unknown error"})
checkInvite(data['code'], delete=True) checkInvite(data["code"], delete=True)
if user.status_code == 200: if user.status_code == 200:
try: try:
policy = data_store.user_template policy = data_store.user_template
if policy != {}: if policy != {}:
jf.setPolicy(user.json()['Id'], policy) jf.setPolicy(user.json()["Id"], policy)
else: else:
log.debug('user policy was blank') log.debug("user policy was blank")
except: except:
log.error('Failed to set new user policy') log.error("Failed to set new user policy")
try: try:
configuration = data_store.user_configuration configuration = data_store.user_configuration
displayprefs = data_store.user_displayprefs displayprefs = data_store.user_displayprefs
if configuration != {} and displayprefs != {}: if configuration != {} and displayprefs != {}:
if jf.setConfiguration(user.json()['Id'], if jf.setConfiguration(user.json()["Id"], configuration):
configuration): jf.setDisplayPreferences(user.json()["Id"], displayprefs)
jf.setDisplayPreferences(user.json()['Id'], log.debug("Set homescreen layout.")
displayprefs)
log.debug('Set homescreen layout.')
else: else:
log.debug('user configuration and/or ' + log.debug(
'displayprefs were blank') "user configuration and/or " + "displayprefs were blank"
)
except: except:
log.error('Failed to set new user homescreen layout') log.error("Failed to set new user homescreen layout")
if config.getboolean('password_resets', 'enabled'): if config.getboolean("password_resets", "enabled"):
data_store.emails[user.json()['Id']] = data['email'] data_store.emails[user.json()["Id"]] = data["email"]
log.debug('Email address stored') log.debug("Email address stored")
log.info('New user created') log.info("New user created")
else: else:
log.error(f'New user creation failed: {user.status_code}') log.error(f"New user creation failed: {user.status_code}")
return resp(False) return resp(False)
else: else:
log.debug('User password invalid') log.debug("User password invalid")
return jsonify(validation) return jsonify(validation)
else: else:
log.debug('Attempted newUser unauthorized') log.debug("Attempted newUser unauthorized")
return resp(False, code=401) return resp(False, code=401)
@app.route('/generateInvite', methods=['POST']) @app.route("/generateInvite", methods=["POST"])
@auth.login_required @auth.login_required
def generateInvite(): def generateInvite():
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
data = request.get_json() data = request.get_json()
delta = datetime.timedelta(hours=int(data['hours']), delta = datetime.timedelta(hours=int(data["hours"]), minutes=int(data["minutes"]))
minutes=int(data['minutes']))
invite_code = secrets.token_urlsafe(16) invite_code = secrets.token_urlsafe(16)
invite = {} invite = {}
log.debug(f'Creating new invite: {invite_code}') log.debug(f"Creating new invite: {invite_code}")
valid_till = current_time + delta valid_till = current_time + delta
invite['valid_till'] = valid_till.strftime('%Y-%m-%dT%H:%M:%S.%f') invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f")
if 'email' in data and config.getboolean('invite_emails', 'enabled'): if "email" in data and config.getboolean("invite_emails", "enabled"):
address = data['email'] address = data["email"]
invite['email'] = address invite["email"] = address
log.info(f'Sending invite to {address}') log.info(f"Sending invite to {address}")
method = config['email']['method'] method = config["email"]["method"]
if method == 'mailgun': if method == "mailgun":
from jellyfin_accounts.email import Mailgun from jellyfin_accounts.email import Mailgun
email = Mailgun(address) email = Mailgun(address)
elif method == 'smtp': elif method == "smtp":
from jellyfin_accounts.email import Smtp from jellyfin_accounts.email import Smtp
email = Smtp(address) email = Smtp(address)
email.construct_invite({'expiry': valid_till, email.construct_invite({"expiry": valid_till, "code": invite_code})
'code': invite_code})
response = email.send() response = email.send()
if response is False or type(response) != bool: 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 data_store.invites[invite_code] = invite
log.info(f'New invite created: {invite_code}') log.info(f"New invite created: {invite_code}")
return resp() return resp()
@app.route('/getInvites', methods=['GET']) @app.route("/getInvites", methods=["GET"])
@auth.login_required @auth.login_required
def getInvites(): def getInvites():
log.debug('Invites requested') log.debug("Invites requested")
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
invites = dict(data_store.invites) invites = dict(data_store.invites)
for code in invites: for code in invites:
checkInvite(code) checkInvite(code)
invites = dict(data_store.invites) invites = dict(data_store.invites)
response = {'invites': []} response = {"invites": []}
for code in invites: for code in invites:
expiry = datetime.datetime.strptime(invites[code]['valid_till'], expiry = datetime.datetime.strptime(
'%Y-%m-%dT%H:%M:%S.%f') invites[code]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
)
valid_for = expiry - current_time valid_for = expiry - current_time
invite = {'code': code, invite = {
'hours': valid_for.seconds//3600, "code": code,
'minutes': (valid_for.seconds//60) % 60} "hours": valid_for.seconds // 3600,
if 'email' in invites[code]: "minutes": (valid_for.seconds // 60) % 60,
invite['email'] = invites[code]['email'] }
response['invites'].append(invite) if "email" in invites[code]:
invite["email"] = invites[code]["email"]
response["invites"].append(invite)
return jsonify(response) return jsonify(response)
@app.route('/deleteInvite', methods=['POST'])
@app.route("/deleteInvite", methods=["POST"])
@auth.login_required @auth.login_required
def deleteInvite(): def deleteInvite():
code = request.get_json()['code'] code = request.get_json()["code"]
invites = dict(data_store.invites) invites = dict(data_store.invites)
if code in invites: if code in invites:
del data_store.invites[code] del data_store.invites[code]
log.info(f'Invite deleted: {code}') log.info(f"Invite deleted: {code}")
return resp() return resp()
@app.route('/getToken') @app.route("/getToken")
@auth.login_required @auth.login_required
def get_token(): def get_token():
token = g.user.generate_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 @auth.login_required
def getUsers(): def getUsers():
log.debug('User and email list requested') log.debug("User and email list requested")
response = {'users': []} response = {"users": []}
users = jf.getUsers(public=False) users = jf.getUsers(public=False)
emails = data_store.emails emails = data_store.emails
for user in users: for user in users:
entry = {'name': user['Name']} entry = {"name": user["Name"]}
if user['Id'] in emails: if user["Id"] in emails:
entry['email'] = emails[user['Id']] entry["email"] = emails[user["Id"]]
response['users'].append(entry) response["users"].append(entry)
return jsonify(response) return jsonify(response)
@app.route('/modifyUsers', methods=['POST']) @app.route("/modifyUsers", methods=["POST"])
@auth.login_required @auth.login_required
def modifyUsers(): def modifyUsers():
data = request.get_json() data = request.get_json()
log.debug('Email list modification requested') log.debug("Email list modification requested")
for key in data: for key in data:
uid = jf.getUsers(key, public=False)['Id'] uid = jf.getUsers(key, public=False)["Id"]
data_store.emails[uid] = data[key] data_store.emails[uid] = data[key]
log.debug(f'Email for user "{key}" modified') log.debug(f'Email for user "{key}" modified')
return resp() return resp()
@app.route('/setDefaults', methods=['POST']) @app.route("/setDefaults", methods=["POST"])
@auth.login_required @auth.login_required
def setDefaults(): def setDefaults():
data = request.get_json() data = request.get_json()
username = data['username'] username = data["username"]
log.debug(f'Storing default settings from user {username}') log.debug(f"Storing default settings from user {username}")
try: try:
user = jf.getUsers(username=username, user = jf.getUsers(username=username, public=False)
public=False)
except Jellyfin.UserNotFoundError: 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) return resp(False)
uid = user['Id'] uid = user["Id"]
policy = user['Policy'] policy = user["Policy"]
data_store.user_template = policy data_store.user_template = policy
if data['homescreen']: if data["homescreen"]:
configuration = user['Configuration'] configuration = user["Configuration"]
try: try:
displayprefs = jf.getDisplayPreferences(uid) displayprefs = jf.getDisplayPreferences(uid)
data_store.user_configuration = configuration data_store.user_configuration = configuration
data_store.user_displayprefs = displayprefs data_store.user_displayprefs = displayprefs
except: except:
log.error('Storing defaults failed: ' + log.error("Storing defaults failed: " + "couldn't store homescreen layout")
'couldn\'t store homescreen layout')
return resp(False) return resp(False)
return resp() return resp()
import jellyfin_accounts.setup
import jellyfin_accounts.setup

246
poetry.lock generated
View File

@ -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]] [[package]]
category = "main" category = "main"
description = "Python package for providing Mozilla's CA Bundle." 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.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "7.1.2" 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]] [[package]]
category = "main" category = "main"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
@ -94,6 +124,14 @@ version = "3.3.0"
[package.dependencies] [package.dependencies]
Flask = "*" Flask = "*"
[[package]]
category = "dev"
description = "Lightweight in-process concurrent programming"
name = "greenlet"
optional = false
python-versions = "*"
version = "0.4.16"
[[package]] [[package]]
category = "main" category = "main"
description = "Internationalized Domain Names in Applications (IDNA)" 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.*" python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
version = "1.1.1" 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]] [[package]]
category = "main" category = "main"
description = "comprehensive password hashing framework supporting over 30 schemes" 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)"] build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.0)"]
totp = ["cryptography"] 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]] [[package]]
category = "main" category = "main"
description = "File system general utilities" description = "File system general utilities"
@ -162,6 +227,22 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.20" 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]] [[package]]
category = "main" category = "main"
description = "Python wrapper module around the OpenSSL library" description = "Python wrapper module around the OpenSSL library"
@ -197,6 +278,14 @@ optional = false
python-versions = "*" python-versions = "*"
version = "2020.1" 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]] [[package]]
category = "main" category = "main"
description = "Python HTTP for Humans." description = "Python HTTP for Humans."
@ -223,6 +312,22 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.15.0" 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]] [[package]]
category = "main" category = "main"
description = "HTTP library with thread-safe connection pooling, file post, and more." 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"] watchdog = ["watchdog"]
[metadata] [metadata]
content-hash = "fd63698aba27900fe4068b86c7042725a7210e647429dad462966febcf2047b9" content-hash = "f07c7cafa4edc558a016b9b7742290d7f28579b4e350762d2afbdce21f71796b"
python-versions = "^3.6" python-versions = "^3.6"
[metadata.files] [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 = [ certifi = [
{file = "certifi-2020.4.5.2-py2.py3-none-any.whl", hash = "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc"}, {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"}, {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-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, {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 = [ cryptography = [
{file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"}, {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"}, {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.tar.gz", hash = "sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9"},
{file = "Flask_HTTPAuth-3.3.0-py2.py3-none-any.whl", hash = "sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72"}, {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 = [ idna = [
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
{file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, {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-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, {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 = [ passlib = [
{file = "passlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177"}, {file = "passlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177"},
{file = "passlib-1.7.2.tar.gz", hash = "sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8"}, {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 = [ pathtools = [
{file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"}, {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-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
] ]
pynvim = [
{file = "pynvim-0.4.1.tar.gz", hash = "sha256:55e918d664654cfa1c9889d3dbe7c63e9f338df5d49471663f78d54c85e84c58"},
]
pyopenssl = [ pyopenssl = [
{file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"}, {file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
{file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"}, {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-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"},
{file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, {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 = [ requests = [
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, {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-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, {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 = [ urllib3 = [
{file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"}, {file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"},
{file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, {file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"},

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "jellyfin-accounts" name = "jellyfin-accounts"
version = "0.2.0" version = "0.2.1"
readme = "README.md" readme = "README.md"
description = "A simple account management system for Jellyfin" description = "A simple account management system for Jellyfin"
authors = ["Harvey Tindall <harveyltindall@gmail.com>"] authors = ["Harvey Tindall <harveyltindall@gmail.com>"]
@ -28,10 +28,11 @@ passlib = "^1.7.2"
pytz = "^2020.1" pytz = "^2020.1"
python-dateutil = "^2.8.1" python-dateutil = "^2.8.1"
watchdog = "^0.10.2" watchdog = "^0.10.2"
configparser = "^5.0.0"
waitress = "^1.4.3" waitress = "^1.4.3"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
neovim = "^0.3.1"
black = "^19.10b0"
[tool.poetry.scripts] [tool.poetry.scripts]