Formatted with black

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

View File

@@ -1,24 +1,26 @@
import json
import datetime
class JSONFile(dict):
"""
Behaves like a dictionary, but automatically
reads and writes to a JSON file (most of the time).
"""
@staticmethod
def readJSON(path):
try:
with open(path, 'r') as f:
with open(path, "r") as f:
return json.load(f)
except FileNotFoundError:
return {}
@staticmethod
def writeJSON(path, data):
with open(path, 'w') as f:
with open(path, "w") as f:
return f.write(json.dumps(data, indent=4, default=str))
def __init__(self, path, data=None):
self.path = path
if data is None:
@@ -34,14 +36,14 @@ class JSONFile(dict):
def __setitem__(self, key, value):
data = self.readJSON(self.path)
data[key] = value
self.writeJSON(self.path, data)
self.writeJSON(self.path, data)
super(JSONFile, self).__init__(data)
def __delitem__(self, key):
data = self.readJSON(self.path)
super(JSONFile, self).__init__(data)
del data[key]
self.writeJSON(self.path, data)
self.writeJSON(self.path, data)
super(JSONFile, self).__delitem__(key)
def __str__(self):
@@ -50,18 +52,15 @@ class JSONFile(dict):
class JSONStorage:
def __init__(self,
emails,
invites,
user_template,
user_displayprefs,
user_configuration):
def __init__(
self, emails, invites, user_template, user_displayprefs, user_configuration
):
self.emails = JSONFile(path=emails)
self.invites = JSONFile(path=invites)
self.user_template = JSONFile(path=user_template)
self.user_displayprefs = JSONFile(path=user_displayprefs)
self.user_configuration = JSONFile(path=user_configuration)
def __setattr__(self, name, value):
if hasattr(self, name):
path = self.__dict__[name].path

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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