Proper dynamic config reload

A bunch of options can now be changed without a restart as the config is
now guaranteed to be reloaded on change through the use of a RELOADCONFIG environment variable.
This commit is contained in:
Harvey Tindall 2020-07-12 19:53:04 +01:00
parent 27169e4e0d
commit d615b21c7d
15 changed files with 148 additions and 115 deletions

View File

@ -9,9 +9,9 @@ server = http://jellyfin.local:8096
public_server = https://jellyf.in:443 public_server = https://jellyf.in:443
; this and below settings will show on the jellyfin dashboard when the program connects. you may as well leave them alone. ; this and below settings will show on the jellyfin dashboard when the program connects. you may as well leave them alone.
client = jf-accounts client = jf-accounts
version = 0.3.2 version = 0.3.6
device = jf-accounts device = jf-accounts
device_id = jf-accounts-0.3.2 device_id = jf-accounts-0.3.6
[ui] [ui]
; settings related to the ui and program functionality. ; settings related to the ui and program functionality.
@ -31,7 +31,7 @@ password = your password
debug = false debug = false
; displayed at bottom of all pages except admin ; displayed at bottom of all pages except admin
contact_message = Need help? contact me. contact_message = Need help? contact me.
; display at top of invite form. ; displayed at top of invite form.
help_message = Enter your details to create an account. help_message = Enter your details to create an account.
; displayed when a user creates an account ; displayed when a user creates an account
success_message = Your account has been created. Click below to continue to Jellyfin. success_message = Your account has been created. Click below to continue to Jellyfin.

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python3 # Runs it!
__version__ = "0.3.6" __version__ = "0.3.6"
import secrets import secrets
@ -13,6 +13,7 @@ import json
from pathlib import Path from pathlib import Path
from flask import Flask, jsonify, g from flask import Flask, jsonify, g
from jellyfin_accounts.data_store import JSONStorage from jellyfin_accounts.data_store import JSONStorage
from jellyfin_accounts.config import Config
parser = argparse.ArgumentParser(description="jellyfin-accounts") parser = argparse.ArgumentParser(description="jellyfin-accounts")
@ -69,7 +70,7 @@ if data_dir.exists() is False or (data_dir / "config.ini").exists() is False:
else: else:
config_path = data_dir / "config.ini" config_path = data_dir / "config.ini"
# Temp config so logger knows whether to use debug mode or not
temp_config = configparser.RawConfigParser() temp_config = configparser.RawConfigParser()
temp_config.read(config_path) temp_config.read(config_path)
@ -93,61 +94,7 @@ def create_log(name):
log = create_log("main") log = create_log("main")
config = Config(config_path, secrets.token_urlsafe(16), data_dir, local_dir, log)
def load_config(config_path, data_dir):
config = configparser.RawConfigParser()
config.read(config_path)
global log
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"))
if "no_username" not in config["email"]:
config["email"]["no_username"] = "false"
log.debug("Set no_username to false")
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 "bs5" not in config["ui"] or config["ui"]["bs5"] == "":
config["ui"]["bs5"] = "false"
return config
config = load_config(config_path, data_dir)
web_log = create_log("waitress") web_log = create_log("waitress")
if not first_run: if not first_run:

View File

@ -0,0 +1,95 @@
import os
import configparser
import secrets
from pathlib import Path
class Config:
"""
Configuration object that can automatically reload modified settings.
Behaves mostly like a dictionary.
:param file: Path to config.ini, where parameters are set.
:param instance: Used to identify specific jf-accounts instances in environment variables.
:param data_dir: Path to directory with config, invites, templates, etc.
:param local_dir: Path to internally stored config base, emails, etc.
"""
@staticmethod
def load_config(config_path, data_dir, local_dir, log):
config = configparser.RawConfigParser()
config.read(config_path)
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"))
if "no_username" not in config["email"]:
config["email"]["no_username"] = "false"
log.debug("Set no_username to false")
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 "bs5" not in config["ui"] or config["ui"]["bs5"] == "":
config["ui"]["bs5"] = "false"
return config
def __init__(self, file, instance, data_dir, local_dir, log):
self.config_path = Path(file)
self.data_dir = data_dir
self.local_dir = local_dir
self.instance = instance
self.log = log
self.varname = f"JFA_{self.instance}_RELOADCONFIG"
os.environ[self.varname] = "true"
def __getitem__(self, key):
if os.environ[self.varname] == "true":
self.config = Config.load_config(
self.config_path, self.data_dir, self.local_dir, self.log
)
os.environ[self.varname] = "false"
return self.config.__getitem__(key)
def getboolean(self, sect, key):
if os.environ[self.varname] == "true":
self.config = Config.load_config(
self.config_path, self.data_dir, self.local_dir, self.log
)
os.environ[self.varname] = "false"
return self.config.getboolean(sect, key)
def trigger_reload(self):
os.environ[self.varname] = "true"

View File

@ -143,7 +143,7 @@
"contact_message": { "contact_message": {
"name": "Contact message", "name": "Contact message",
"required": false, "required": false,
"requires_restart": true, "requires_restart": false,
"type": "text", "type": "text",
"value": "Need help? contact me.", "value": "Need help? contact me.",
"description": "Displayed at bottom of all pages except admin" "description": "Displayed at bottom of all pages except admin"
@ -151,15 +151,15 @@
"help_message": { "help_message": {
"name": "Help message", "name": "Help message",
"required": false, "required": false,
"requires_restart": true, "requires_restart": false,
"type": "text", "type": "text",
"value": "Enter your details to create an account.", "value": "Enter your details to create an account.",
"description": "Display at top of invite form." "description": "Displayed at top of invite form."
}, },
"success_message": { "success_message": {
"name": "Success message", "name": "Success message",
"required": false, "required": false,
"requires_restart": true, "requires_restart": false,
"type": "text", "type": "text",
"value": "Your account has been created. Click below to continue to Jellyfin.", "value": "Your account has been created. Click below to continue to Jellyfin.",
"description": "Displayed when a user creates an account" "description": "Displayed when a user creates an account"
@ -167,7 +167,7 @@
"bs5": { "bs5": {
"name": "Use Bootstrap 5", "name": "Use Bootstrap 5",
"required": false, "required": false,
"requires_restart": true, "requires_restart": false,
"type": "bool", "type": "bool",
"value": false, "value": false,
"description": "Use Bootstrap 5 (currently in alpha). This also removes the need for jQuery, so the page should load faster." "description": "Use Bootstrap 5 (currently in alpha). This also removes the need for jQuery, so the page should load faster."
@ -181,41 +181,41 @@
"enabled": { "enabled": {
"name": "Enabled", "name": "Enabled",
"required": false, "required": false,
"requires_restart": true, "requires_restart": false,
"type": "bool", "type": "bool",
"value": true "value": true
}, },
"min_length": { "min_length": {
"name": "Minimum Length", "name": "Minimum Length",
"requires_restart": true, "requires_restart": false,
"depends_true": "enabled", "depends_true": "enabled",
"type": "text", "type": "text",
"value": "8" "value": "8"
}, },
"upper": { "upper": {
"name": "Minimum uppercase characters", "name": "Minimum uppercase characters",
"requires_restart": true, "requires_restart": false,
"depends_true": "enabled", "depends_true": "enabled",
"type": "text", "type": "text",
"value": "1" "value": "1"
}, },
"lower": { "lower": {
"name": "Minimum lowercase characters", "name": "Minimum lowercase characters",
"requires_restart": true, "requires_restart": false,
"depends_true": "enabled", "depends_true": "enabled",
"type": "text", "type": "text",
"value": "0" "value": "0"
}, },
"number": { "number": {
"name": "Minimum number count", "name": "Minimum number count",
"requires_restart": true, "requires_restart": false,
"depends_true": "enabled", "depends_true": "enabled",
"type": "text", "type": "text",
"value": "1" "value": "1"
}, },
"special": { "special": {
"name": "Minimum number of special characters", "name": "Minimum number of special characters",
"requires_restart": true, "requires_restart": false,
"depends_true": "enabled", "depends_true": "enabled",
"type": "text", "type": "text",
"value": "0" "value": "0"
@ -229,7 +229,7 @@
"no_username": { "no_username": {
"name": "Use email addresses as username", "name": "Use email addresses as username",
"required": false, "required": false,
"requires_restart": true, "requires_restart": false,
"depends_true": "method", "depends_true": "method",
"type": "bool", "type": "bool",
"value": false, "value": false,
@ -350,7 +350,7 @@
"enabled": { "enabled": {
"name": "Enabled", "name": "Enabled",
"required": false, "required": false,
"requires_restart": true, "requires_restart": false,
"type": "bool", "type": "bool",
"value": true "value": true
}, },

View File

@ -35,19 +35,15 @@
{% else %} {% else %}
const bsVersion = 4; const bsVersion = 4;
{% endif %} {% endif %}
console.log('create');
var css = document.createElement('link'); var css = document.createElement('link');
css.setAttribute('rel', 'stylesheet'); css.setAttribute('rel', 'stylesheet');
css.setAttribute('type', 'text/css'); css.setAttribute('type', 'text/css');
var cssCookie = getCookie("css"); var cssCookie = getCookie("css");
if (cssCookie.includes('bs' + bsVersion)) { if (cssCookie.includes('bs' + bsVersion)) {
console.log('href');
css.setAttribute('href', cssCookie); css.setAttribute('href', cssCookie);
} else { } else {
console.log('href');
css.setAttribute('href', '{{ css_file }}'); css.setAttribute('href', '{{ css_file }}');
}; };
console.log('append');
document.head.appendChild(css); document.head.appendChild(css);
</script> </script>
{% if not bs5 %} {% if not bs5 %}

View File

@ -1,3 +1,4 @@
# Automatic storage of everything except the config
import json import json
import datetime import datetime

View File

@ -1,3 +1,4 @@
# Handles everything related to emails
import datetime import datetime
import pytz import pytz
import requests import requests
@ -43,9 +44,7 @@ class Email:
if expires_in["hours"] == 0: 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 = ( expires_in = f'{str(expires_in["hours"])}h {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}") log.debug(f"{self.address}: Expires in {expires_in}")
return {"date": date, "time": time, "expires_in": expires_in} return {"date": date, "time": time, "expires_in": expires_in}

View File

@ -1,3 +1,4 @@
# Generates config file
import configparser import configparser
import json import json
from pathlib import Path from pathlib import Path

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python3 # Jellyfin API client
import requests import requests
import time import time

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python3 # Handles authentication
from flask_httpauth import HTTPBasicAuth from flask_httpauth import HTTPBasicAuth
from itsdangerous import ( from itsdangerous import (

View File

@ -1,3 +1,4 @@
# Watches Jellyfin for password resets and sends emails.
import time import time
import json import json
from watchdog.observers import Observer from watchdog.observers import Observer

View File

@ -1,3 +1,4 @@
# Views and endpoints for the initial setup
from flask import request, jsonify, render_template from flask import request, jsonify, render_template
from configparser import RawConfigParser from configparser import RawConfigParser
from jellyfin_accounts.jf_api import Jellyfin from jellyfin_accounts.jf_api import Jellyfin

View File

@ -1,3 +1,4 @@
# Password validation
specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')', specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')',
'<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']'] '<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']']

View File

@ -1,15 +1,16 @@
# Web views
from pathlib import Path from pathlib import Path
from flask import Flask, send_from_directory, render_template from flask import Flask, send_from_directory, render_template
from jellyfin_accounts import app, g, css_file, data_store from jellyfin_accounts import config, app, g, css_file, data_store
from jellyfin_accounts import web_log as log from jellyfin_accounts import web_log as log
from jellyfin_accounts.web_api import config, checkInvite, validator from jellyfin_accounts.web_api import checkInvite, validator
if config.getboolean("ui", "bs5"): def bsVersion():
bsVersion = 5 if config.getboolean("ui", "bs5"):
else: return 5
bsVersion = 4 return 4
@app.errorhandler(404) @app.errorhandler(404)
@ -42,7 +43,7 @@ def static_proxy(path):
if "html" not in path: if "html" not in path:
if "admin.js" in path: if "admin.js" in path:
return ( return (
render_template("admin.js", bsVersion=bsVersion, css_file=css_file), render_template("admin.js", bsVersion=bsVersion(), css_file=css_file),
200, 200,
{"Content-Type": "text/javascript"}, {"Content-Type": "text/javascript"},
) )
@ -75,7 +76,7 @@ def inviteProxy(path):
successMessage=config["ui"]["success_message"], successMessage=config["ui"]["success_message"],
jfLink=config["jellyfin"]["public_server"], jfLink=config["jellyfin"]["public_server"],
validate=config.getboolean("password_validation", "enabled"), validate=config.getboolean("password_validation", "enabled"),
requirements=validator.getCriteria(), requirements=validator().getCriteria(),
email=email, email=email,
username=(not config.getboolean("email", "no_username")), username=(not config.getboolean("email", "no_username")),
) )

View File

@ -1,3 +1,4 @@
# A bit of a mess, but mostly does API endpoints and a couple compatability fixes
from flask import request, jsonify from flask import request, jsonify
from jellyfin_accounts.jf_api import Jellyfin from jellyfin_accounts.jf_api import Jellyfin
import json import json
@ -7,8 +8,6 @@ import time
from jellyfin_accounts import ( from jellyfin_accounts import (
config, config,
config_path, config_path,
load_config,
data_dir,
app, app,
g, g,
data_store, data_store,
@ -136,11 +135,11 @@ if (
version.parse(jf.info["Version"]) >= version.parse("10.6.0") version.parse(jf.info["Version"]) >= version.parse("10.6.0")
and bool(data_store.user_template) is not False and bool(data_store.user_template) is not False
): ):
log.info("Updating user_template for Jellyfin >= 10.6.0")
if ( if (
data_store.user_template["AuthenticationProviderId"] data_store.user_template["AuthenticationProviderId"]
== "Emby.Server.Implementations.Library.DefaultAuthenticationProvider" == "Emby.Server.Implementations.Library.DefaultAuthenticationProvider"
): ):
log.info("Updating user_template for Jellyfin >= 10.6.0")
data_store.user_template[ data_store.user_template[
"AuthenticationProviderId" "AuthenticationProviderId"
] = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider" ] = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider"
@ -153,16 +152,16 @@ if (
] = "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider" ] = "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider"
if config.getboolean("password_validation", "enabled"): def validator():
validator = PasswordValidator( if config.getboolean("password_validation", "enabled"):
config["password_validation"]["min_length"], return 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: )
validator = PasswordValidator(0, 0, 0, 0, 0) return PasswordValidator(0, 0, 0, 0, 0)
@app.route("/newUser", methods=["POST"]) @app.route("/newUser", methods=["POST"])
@ -170,7 +169,7 @@ 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:
@ -203,9 +202,7 @@ def newUser():
jf.setDisplayPreferences(user.json()["Id"], displayprefs) jf.setDisplayPreferences(user.json()["Id"], displayprefs)
log.debug("Set homescreen layout.") log.debug("Set homescreen layout.")
else: else:
log.debug( log.debug("user configuration and/or 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"):
@ -392,18 +389,11 @@ def modifyConfig():
log.debug(f"{section}/{item} modified") log.debug(f"{section}/{item} modified")
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)
config = load_config(config_path, data_dir) config.trigger_reload()
log.info("Config written. Restart may be needed to load settings.") log.info("Config written. Restart may be needed to load settings.")
return resp() return resp()
# @app.route('/getConfig', methods=["GET"])
# @auth.login_required
# def getConfig():
# log.debug('Config requested')
# return jsonify(config._sections), 200
@app.route("/getConfig", methods=["GET"]) @app.route("/getConfig", methods=["GET"])
@auth.login_required @auth.login_required
def getConfig(): def getConfig():