From f4f18d41eae54a60b73b3e995ebc57f0f1356152 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 14 Jun 2020 17:58:18 +0100 Subject: [PATCH] Modularized JSON storage user_template and other files are now accessed via JSONStorage, which has dictionary like attributes for each file which can be used like a dictionary, without the need to manually read and write the file. This was done so that other storage types (e.g a database) can be added in future. --- jellyfin_accounts/data_store.py | 66 +++++++++ jellyfin_accounts/pw_reset.py | 25 ++-- jellyfin_accounts/web.py | 15 +- jellyfin_accounts/web_api.py | 237 ++++++++++++-------------------- jf-accounts | 38 +++-- 5 files changed, 197 insertions(+), 184 deletions(-) create mode 100644 jellyfin_accounts/data_store.py diff --git a/jellyfin_accounts/data_store.py b/jellyfin_accounts/data_store.py new file mode 100644 index 0000000..54d5517 --- /dev/null +++ b/jellyfin_accounts/data_store.py @@ -0,0 +1,66 @@ +import json +import datetime + +class JSONFile(dict): + @staticmethod + def readJSON(path): + try: + with open(path, 'r') as f: + return json.load(f) + except FileNotFoundError: + return {} + + @staticmethod + def writeJSON(path, data): + 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: + super(JSONFile, self).__init__(self.readJSON(self.path)) + else: + super(JSONFile, self).__init__(data) + self.writeJSON(self.path, data) + + def __getitem__(self, key): + super(JSONFile, self).__init__(self.readJSON(self.path)) + return super(JSONFile, self).__getitem__(key) + + def __setitem__(self, key, value): + data = self.readJSON(self.path) + data[key] = value + 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) + super(JSONFile, self).__delitem__(key) + + def __str__(self): + super(JSONFile, self).__init__(self.readJSON(self.path)) + return json.dumps(super(JSONFile, self)) + + +class JSONStorage: + 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 + self.__dict__[name] = JSONFile(path=path, data=value) + else: + self.__dict__[name] = value diff --git a/jellyfin_accounts/pw_reset.py b/jellyfin_accounts/pw_reset.py index c46cd37..4b6c664 100755 --- a/jellyfin_accounts/pw_reset.py +++ b/jellyfin_accounts/pw_reset.py @@ -4,7 +4,7 @@ from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from jellyfin_accounts.email import Mailgun, Smtp from jellyfin_accounts.web_api import jf -from __main__ import config +from __main__ import config, data_store from __main__ import email_log as log @@ -42,17 +42,18 @@ class Handler(FileSystemEventHandler): reset = json.load(f) log.info(f'New password reset for {reset["UserName"]}') try: - with open(config['files']['emails'], 'r') as f: - emails = json.load(f) - id = jf.getUsers(reset['UserName'], public=False)['Id'] - address = emails[id] - method = config['email']['method'] - if method == 'mailgun': - email = Mailgun(address) - elif method == 'smtp': - email = Smtp(address) - if email.construct_reset(reset): - email.send() + id = jf.getUsers(reset['UserName'], public=False)['Id'] + address = data_store.emails[id] + if address != '': + method = config['email']['method'] + if method == 'mailgun': + email = Mailgun(address) + elif method == 'smtp': + email = Smtp(address) + if email.construct_reset(reset): + email.send() + else: + raise IndexError except (FileNotFoundError, json.decoder.JSONDecodeError, IndexError) as e: diff --git a/jellyfin_accounts/web.py b/jellyfin_accounts/web.py index 2ab8188..8907a6b 100644 --- a/jellyfin_accounts/web.py +++ b/jellyfin_accounts/web.py @@ -1,7 +1,7 @@ import json from pathlib import Path from flask import Flask, send_from_directory, render_template -from __main__ import config, app, g, css +from __main__ import config, app, g, css, data_store from __main__ import web_log as log from jellyfin_accounts.web_api import checkInvite, validator @@ -43,16 +43,9 @@ def inviteProxy(path): if checkInvite(path): log.info(f'Invite {path} used to request form') try: - with open(config['files']['invites'], 'r') as f: - invites = json.load(f) - except (FileNotFoundError, json.decoder.JSONDecodeError): - invites = {'invites': []} - for invite in invites['invites']: - if invite['code'] == path: - try: - email = invite['email'] - except KeyError: - email = "" + email = data_store.invites[path]['email'] + except KeyError: + email = '' return render_template('form.html', css_href=css['href'], css_integrity=css['integrity'], diff --git a/jellyfin_accounts/web_api.py b/jellyfin_accounts/web_api.py index d5b2551..eb34265 100644 --- a/jellyfin_accounts/web_api.py +++ b/jellyfin_accounts/web_api.py @@ -1,48 +1,40 @@ from flask import request, jsonify -from configparser import RawConfigParser from jellyfin_accounts.jf_api import Jellyfin import json import datetime import secrets import time -from __main__ import config, config_path, app, g +from __main__ import config, config_path, app, g, data_store from __main__ 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.status_code = 200 + if code == 500: + r.status_code = 200 + else: + r.status_code = code else: r = jsonify({'success': False}) r.status_code = code return r - def checkInvite(code, delete=False): current_time = datetime.datetime.now() - try: - with open(config['files']['invites'], 'r') as f: - invites = json.load(f) - except (FileNotFoundError, json.decoder.JSONDecodeError): - invites = {'invites': []} - valid = False - for index, i in enumerate(invites['invites']): - expiry = datetime.datetime.strptime(i['valid_till'], + 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') if current_time >= expiry: - log.debug(('Housekeeping: Deleting old invite ' + - invites['invites'][index]['code'])) - del invites['invites'][index] - else: - if i['code'] == code: - valid = True - if delete: - del invites['invites'][index] - with open(config['files']['invites'], 'w') as f: - f.write(json.dumps(invites, indent=4, default=str)) - return valid - + log.debug(f'Housekeeping: Deleting old invite {invite}') + del data_store.invites[invite] + elif invite == code: + match = True + if delete: + del data_store.invites[code] + return match jf = Jellyfin(config['jellyfin']['server'], config['jellyfin']['client'], @@ -52,26 +44,22 @@ jf = Jellyfin(config['jellyfin']['server'], from jellyfin_accounts.login import auth -attempts = 0 +jf_address = config['jellyfin']['server'] success = False -while attempts != 3: +for i in range(3): try: jf.authenticate(config['jellyfin']['username'], config['jellyfin']['password']) success = True - log.info(('Successfully authenticated with ' + - config['jellyfin']['server'])) + log.info(f'Successfully authenticated with {jf_address}') break except Jellyfin.AuthenticationError: - attempts += 1 - log.error(('Failed to authenticate with ' + - config['jellyfin']['server'] + - '. 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.') - + exit() def switchToIds(): try: @@ -112,7 +100,7 @@ else: validator = PasswordValidator(0, 0, 0, 0, 0) -@app.route('/newUser', methods=['GET', 'POST']) +@app.route('/newUser', methods=['POST']) def newUser(): data = request.get_json() log.debug('Attempted newUser') @@ -125,12 +113,10 @@ def newUser(): if valid: log.debug('User password valid') try: - jf.authenticate(config['jellyfin']['username'], - config['jellyfin']['password']) - user = jf.newUser(data['username'], data['password']) + user = jf.newUser(data['username'], + data['password']) except Jellyfin.UserExistsError: - error = 'User already exists with name ' - error += data['username'] + error = f'User already exists named {data["username"]}' log.debug(error) return jsonify({'error': error}) except: @@ -138,36 +124,31 @@ def newUser(): checkInvite(data['code'], delete=True) if user.status_code == 200: try: - with open(config['files']['user_template'], 'r') as f: - default_policy = json.load(f) - jf.setPolicy(user.json()['Id'], default_policy) + policy = data_store.user_template + if policy != {}: + jf.setPolicy(user.json()['Id'], policy) + else: + log.debug('user policy was blank') except: - log.error('Failed to set new user policy. ' + - 'Ignore if you didn\'t create a template') + log.error('Failed to set new user policy') try: - with open(config['files']['user_configuration'], 'r') as f: - default_configuration = json.load(f) - with open(config['files']['user_displayprefs'], 'r') as f: - default_displayprefs = json.load(f) - if jf.setConfiguration(user.json()['Id'], - default_configuration): - jf.setDisplayPreferences(user.json()['Id'], - default_displayprefs) - log.debug('Set homescreen layout.') + 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.') + else: + log.debug('user configuration and/or ' + + 'displayprefs were blank') except: - log.error('Failed to set new user homescreen kayout.' + - 'Ignore if you didn\'t create a template') + log.error('Failed to set new user homescreen layout') if config.getboolean('password_resets', 'enabled'): - try: - with open(config['files']['emails'], 'r') as f: - emails = json.load(f) - except (FileNotFoundError, json.decoder.JSONDecodeError): - emails = {} - emails[user.json()['Id']] = data['email'] - with open(config['files']['emails'], 'w') as f: - f.write(json.dumps(emails, indent=4)) + data_store.emails[user.json()['Id']] = data['email'] log.debug('Email address stored') - log.info('New User created.') + log.info('New user created') else: log.error(f'New user creation failed: {user.status_code}') return resp(False) @@ -179,15 +160,16 @@ def newUser(): return resp(False, code=401) -@app.route('/generateInvite', methods=['GET', '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'])) - invite = {'code': secrets.token_urlsafe(16)} - log.debug(f'Creating new invite: {invite["code"]}') + invite_code = secrets.token_urlsafe(16) + invite = {} + 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'): @@ -202,19 +184,12 @@ def generateInvite(): from jellyfin_accounts.email import Smtp email = Smtp(address) email.construct_invite({'expiry': valid_till, - 'code': invite['code']}) + 'code': invite_code}) response = email.send() if response is False or type(response) != bool: invite['email'] = f'Failed to send to {address}' - try: - with open(config['files']['invites'], 'r') as f: - invites = json.load(f) - except (FileNotFoundError, json.decoder.JSONDecodeError): - invites = {'invites': []} - invites['invites'].append(invite) - with open(config['files']['invites'], 'w') as f: - f.write(json.dumps(invites, indent=4, default=str)) - log.info(f'New invite created: {invite["code"]}') + data_store.invites[invite_code] = invite + log.info(f'New invite created: {invite_code}') return resp() @@ -223,46 +198,30 @@ def generateInvite(): def getInvites(): log.debug('Invites requested') current_time = datetime.datetime.now() - try: - with open(config['files']['invites'], 'r') as f: - invites = json.load(f) - except (FileNotFoundError, json.decoder.JSONDecodeError): - invites = {'invites': []} + invites = dict(data_store.invites) + for code in invites: + checkInvite(code) + invites = dict(data_store.invites) response = {'invites': []} - for index, i in enumerate(invites['invites']): - expiry = datetime.datetime.strptime(i['valid_till'], + for code in invites: + expiry = datetime.datetime.strptime(invites[code]['valid_till'], '%Y-%m-%dT%H:%M:%S.%f') - if current_time >= expiry: - log.debug(('Housekeeping: Deleting old invite ' + - invites['invites'][index]['code'])) - del invites['invites'][index] - else: - valid_for = expiry - current_time - invite = {'code': i['code'], - 'hours': valid_for.seconds//3600, - 'minutes': (valid_for.seconds//60) % 60} - if 'email' in i: - invite['email'] = i['email'] - response['invites'].append(invite) - with open(config['files']['invites'], 'w') as f: - f.write(json.dumps(invites, indent=4, default=str)) + 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) return jsonify(response) - @app.route('/deleteInvite', methods=['POST']) @auth.login_required def deleteInvite(): code = request.get_json()['code'] - try: - with open(config['files']['invites'], 'r') as f: - invites = json.load(f) - except (FileNotFoundError, json.decoder.JSONDecodeError): - invites = {'invites': []} - for index, i in enumerate(invites['invites']): - if i['code'] == code: - del invites['invites'][index] - with open(config['files']['invites'], 'w') as f: - f.write(json.dumps(invites, indent=4, default=str)) + invites = dict(data_store.invites) + if code in invites: + del data_store.invites[code] log.info(f'Invite deleted: {code}') return resp() @@ -274,19 +233,13 @@ def get_token(): return jsonify({'token': token.decode('ascii')}) -@app.route('/getUsers', methods=['GET', 'POST']) +@app.route('/getUsers', methods=['GET']) @auth.login_required def getUsers(): log.debug('User and email list requested') - try: - with open(config['files']['emails'], 'r') as f: - emails = json.load(f) - except (FileNotFoundError, json.decoder.JSONDecodeError): - emails = {} response = {'users': []} - jf.authenticate(config['jellyfin']['username'], - config['jellyfin']['password']) users = jf.getUsers(public=False) + emails = data_store.emails for user in users: entry = {'name': user['Name']} if user['Id'] in emails: @@ -294,29 +247,17 @@ def getUsers(): response['users'].append(entry) return jsonify(response) + @app.route('/modifyUsers', methods=['POST']) @auth.login_required def modifyUsers(): data = request.get_json() - log.debug('User and email list modification requested') - try: - with open(config['files']['emails'], 'r') as f: - emails = json.load(f) - except (FileNotFoundError, json.decoder.JSONDecodeError): - emails = {} - jf.authenticate(config['jellyfin']['username'], - config['jellyfin']['password']) + log.debug('Email list modification requested') for key in data: uid = jf.getUsers(key, public=False)['Id'] + data_store.emails[uid] = data[key] log.debug(f'Email for user "{key}" modified') - emails[uid] = data[key] - try: - with open(config['files']['emails'], 'w') as f: - f.write(json.dumps(emails, indent=4)) - return resp() - except: - log.error('Could not store email') - return resp(success=False) + return resp() @app.route('/setDefaults', methods=['POST']) @@ -324,33 +265,27 @@ def modifyUsers(): def setDefaults(): data = request.get_json() username = data['username'] - log.debug(f'storing default settings from user {username}') - jf.authenticate(config['jellyfin']['username'], - config['jellyfin']['password']) + log.debug(f'Storing default settings from user {username}') try: user = jf.getUsers(username=username, public=False) except Jellyfin.UserNotFoundError: - log.error(f'couldn\'t find user {username}') - return resp(success=False) + log.error(f'Storing defaults failed: Couldn\'t find user {username}') + return resp(False) uid = user['Id'] policy = user['Policy'] - try: - with open(config['files']['user_template'], 'w') as f: - f.write(json.dumps(policy, indent=4)) - except: - log.error('Could not store user template') - return resp(success=False) + data_store.user_template = policy if data['homescreen']: configuration = user['Configuration'] try: - display_prefs = jf.getDisplayPreferences(uid) - with open(config['files']['user_configuration'], 'w') as f: - f.write(json.dumps(configuration, indent=4)) - with open(config['files']['user_displayprefs'], 'w') as f: - f.write(json.dumps(display_prefs, indent=4)) + displayprefs = jf.getDisplayPreferences(uid) + data_store.user_configuration = configuration + data_store.user_displayprefs = displayprefs except: - log.error('Could not store homescreen layout') + log.error('Storing defaults failed: ' + + 'couldn\'t store homescreen layout') + return resp(False) return resp() - + import jellyfin_accounts.setup + diff --git a/jf-accounts b/jf-accounts index e3d07a4..5417933 100755 --- a/jf-accounts +++ b/jf-accounts @@ -7,8 +7,10 @@ import logging import threading import signal import sys +import json from pathlib import Path from flask import Flask, g +from jellyfin_accounts.data_store import JSONStorage parser = argparse.ArgumentParser(description="jellyfin-accounts") @@ -97,6 +99,25 @@ for key in ['user_configuration', 'user_displayprefs']: log.debug(f'Using default {key}') config['files'][key] = str(data_dir / (key + '.json')) +with open(config['files']['invites'], 'r') as f: + temp_invites = json.load(f) +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: + 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']) def default_css(): css = {} @@ -148,10 +169,10 @@ 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']) + 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(""" This tool lets you grab various settings from a user, @@ -187,8 +208,7 @@ if args.get_defaults: success = True except (ValueError, IndexError): pass - with open(config['files']['user_template'], 'w') as f: - f.write(json.dumps(policy, indent=4)) + 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') @@ -203,11 +223,9 @@ if args.get_defaults: user_id = users[user_index]['Id'] configuration = users[user_index]['Configuration'] display_prefs = jf.getDisplayPreferences(user_id) - with open(config['files']['user_configuration'], 'w') as f: - f.write(json.dumps(configuration, indent=4)) + data_store.user_configuration = configuration print(f'Configuration written to "{config["files"]["user_configuration"]}".') - with open(config['files']['user_displayprefs'], 'w') as f: - f.write(json.dumps(display_prefs, indent=4)) + data_store.user_displayprefs = display_prefs print(f'Display Prefs written to "{config["files"]["user_displayprefs"]}".') success = True elif choice.lower() == 'n':