diff --git a/jellyfin_accounts/__init__.py b/jellyfin_accounts/__init__.py index cb3d76a..4f2a291 100755 --- a/jellyfin_accounts/__init__.py +++ b/jellyfin_accounts/__init__.py @@ -290,13 +290,6 @@ def main(): success = True else: - - def signal_handler(sig, frame): - 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") @@ -306,6 +299,13 @@ def main(): from waitress import serve if first_run: + + def signal_handler(sig, frame): + print("Quitting...") + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) import jellyfin_accounts.setup host = config["ui"]["host"] @@ -315,6 +315,7 @@ def main(): else: import jellyfin_accounts.web_api import jellyfin_accounts.web + import jellyfin_accounts.invite_daemon host = config["ui"]["host"] port = config["ui"]["port"] @@ -330,4 +331,12 @@ def main(): log.info("Starting email thread") pwr.start() + def signal_handler(sig, frame): + print("Quitting...") + if config.getboolean("notifications", "enabled"): + jellyfin_accounts.invite_daemon.inviteDaemon.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) serve(app, host=host, port=int(port)) diff --git a/jellyfin_accounts/config.py b/jellyfin_accounts/config.py index 203c30c..911e03b 100644 --- a/jellyfin_accounts/config.py +++ b/jellyfin_accounts/config.py @@ -16,6 +16,7 @@ class Config: @staticmethod def load_config(config_path, data_dir, local_dir, log): + # Lord forgive me for this mess config = configparser.RawConfigParser() config.read(config_path) for key in config["files"]: @@ -64,6 +65,31 @@ class Config: config["jellyfin"]["public_server"] = config["jellyfin"]["server"] if "bs5" not in config["ui"] or config["ui"]["bs5"] == "": config["ui"]["bs5"] = "false" + if ( + "expiry_html" not in config["notifications"] + or config["notifications"]["expiry_html"] == "" + ): + log.debug("Using default expiry notification HTML template") + config["notifications"]["expiry_html"] = str(local_dir / "expired.html") + if ( + "expiry_text" not in config["notifications"] + or config["notifications"]["expiry_text"] == "" + ): + log.debug("Using default expiry notification plaintext template") + config["notifications"]["expiry_text"] = str(local_dir / "expired.txt") + if ( + "created_html" not in config["notifications"] + or config["notifications"]["created_html"] == "" + ): + log.debug("Using default user creation notification HTML template") + config["notifications"]["created_html"] = str(local_dir / "created.html") + if ( + "created_text" not in config["notifications"] + or config["notifications"]["created_text"] == "" + ): + log.debug("Using default user creation notification plaintext template") + config["notifications"]["created_text"] = str(local_dir / "created.txt") + return config def __init__(self, file, instance, data_dir, local_dir, log): diff --git a/jellyfin_accounts/data/config-base.json b/jellyfin_accounts/data/config-base.json index a782f7b..08dd0e2 100644 --- a/jellyfin_accounts/data/config-base.json +++ b/jellyfin_accounts/data/config-base.json @@ -133,6 +133,15 @@ "value": "your password", "description": "Password for admin page (Leave blank if using jellyfin_login)" }, + "email": { + "name": "Admin email address", + "required": false, + "requires_restart": false, + "depends_false": "jellyfin_login", + "type": "text", + "value": "example@example.com", + "description": "Address to send notifications to (Leave blank if using jellyfin_login)" + }, "debug": { "name": "Debug logging", "required": false, @@ -391,6 +400,56 @@ "description": "Base URL for jf-accounts. This is necessary because using a reverse proxy means the program has no way of knowing the URL itself." } }, + "notifications": { + "meta": { + "name": "Notifications", + "description": "Notification related settings." + }, + "enabled": { + "name": "Enabled", + "required": "false", + "requires_restart": true, + "type": "bool", + "value": true, + "description": "Enabling adds optional toggles to invites to notify on expiry and user creation." + }, + "expiry_html": { + "name": "Expiry email (HTML)", + "required": false, + "requires_restart": false, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Path to expiry notification email HTML." + }, + "expiry_text": { + "name": "Expiry email (Plaintext)", + "required": false, + "requires_restart": "false", + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Path to expiry notification email in plaintext." + }, + "created_html": { + "name": "User created email (HTML)", + "required": false, + "requires_restart": false, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Path to user creation notification email HTML." + }, + "created_text": { + "name": "User created email (Plaintext)", + "required": false, + "requires_restart": false, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Path to user creation notification email in plaintext." + } + }, "mailgun": { "meta": { "name": "Mailgun (Email)", diff --git a/jellyfin_accounts/data/created.txt b/jellyfin_accounts/data/created.txt new file mode 100644 index 0000000..154fbe0 --- /dev/null +++ b/jellyfin_accounts/data/created.txt @@ -0,0 +1,7 @@ +A user was created using code {{ code }}. + +Name: {{ username }} +Address: {{ address }} +Time: {{ time }} + +Note: Notification emails can be toggled on the admin dashboard. diff --git a/jellyfin_accounts/data/email.txt b/jellyfin_accounts/data/email.txt new file mode 100644 index 0000000..43cd930 --- /dev/null +++ b/jellyfin_accounts/data/email.txt @@ -0,0 +1,10 @@ +Hi {{ username }}, + +Someone has recently requests a password reset on Jellyfin. +If this was you, enter the below pin into the prompt. +This code will expire on {{ expiry_date }}, at {{ expiry_time }} , which is in {{ expires_in }}. +If this wasn't you, please ignore this email. + +PIN: {{ pin }} + +{{ message }} diff --git a/jellyfin_accounts/data/expired.txt b/jellyfin_accounts/data/expired.txt new file mode 100644 index 0000000..3541d16 --- /dev/null +++ b/jellyfin_accounts/data/expired.txt @@ -0,0 +1,5 @@ +Invite expired. + +Code {{ code }} expired at {{ expiry }}. + +Note: Notification emails can be toggled on the admin dashboard. diff --git a/jellyfin_accounts/data/invite-email.txt b/jellyfin_accounts/data/invite-email.txt new file mode 100644 index 0000000..601e4a6 --- /dev/null +++ b/jellyfin_accounts/data/invite-email.txt @@ -0,0 +1,8 @@ +Hi, +You've been invited to Jellyfin. +To join, follow the below link. +This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick. + +{{ invite_link }} + +{{ message }} diff --git a/jellyfin_accounts/data/templates/admin.js b/jellyfin_accounts/data/templates/admin.js index d6d626e..96f1a9e 100644 --- a/jellyfin_accounts/data/templates/admin.js +++ b/jellyfin_accounts/data/templates/admin.js @@ -158,7 +158,7 @@ var userDefaultsModal = createModal('userDefaults'); var usersModal = createModal('users'); var restartModal = createModal('restartModal'); -// Parsed invite: [, , <1: Empty invite (no delete/link), 0: Actual invite>, , , [], ] +// Parsed invite: [, , <1: Empty invite (no delete/link), 0: Actual invite>, , , [], , , ] function parseInvite(invite, empty = false) { if (empty) { return ["None", "", 1]; @@ -185,6 +185,12 @@ function parseInvite(invite, empty = false) { if ('created' in invite) { i[6] = invite['created']; } + if ('notify-expiry' in invite) { + i[7] = invite['notify-expiry']; + } + if ('notify-creation' in invite) { + i[8] = invite['notify-creation']; + } return i; } @@ -292,6 +298,78 @@ function addItem(parsedInvite) { dropdownLeft.appendChild(leftList); dropdownContent.appendChild(dropdownLeft); + {% if notifications %} + let dropdownMiddle = document.createElement('div'); + dropdownMiddle.id = parsedInvite[0] + '_notifyButtons'; + dropdownMiddle.classList.add('col'); + + let middleList = document.createElement('ul'); + middleList.classList.add('list-group', 'list-group-flush'); + middleList.textContent = 'Notify on:'; + + let notifyExpiry = document.createElement('li'); + notifyExpiry.classList.add('list-group-item', 'py-1', 'form-check'); + notifyExpiry.innerHTML = ` + + + `; + if (typeof(parsedInvite[7]) == 'boolean') { + notifyExpiry.getElementsByTagName('input')[0].checked = parsedInvite[7]; + } + + notifyExpiry.getElementsByTagName('input')[0].onclick = function() { + let req = new XMLHttpRequest(); + var thisEl = this; + let send = {}; + let code = thisEl.id.replace('_notifyExpiry', ''); + send[code] = {}; + send[code]['notify-expiry'] = thisEl.checked; + req.open("POST", "/setNotify", true); + req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":")); + req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + req.onreadystatechange = function() { + if (this.readyState == 4 && this.status != 200) { + thisEl.checked = !thisEl.checked; + } + }; + req.send(JSON.stringify(send)); + }; + middleList.appendChild(notifyExpiry); + + let notifyCreation = document.createElement('li'); + notifyCreation.classList.add('list-group-item', 'py-1', 'form-check'); + notifyCreation.innerHTML = ` + + + `; + if (typeof(parsedInvite[8]) == 'boolean') { + notifyCreation.getElementsByTagName('input')[0].checked = parsedInvite[8]; + } + notifyCreation.getElementsByTagName('input')[0].onclick = function() { + let req = new XMLHttpRequest(); + var thisEl = this; + let send = {}; + let code = thisEl.id.replace('_notifyCreation', ''); + send[code] = {}; + send[code]['notify-creation'] = thisEl.checked; + req.open("POST", "/setNotify", true); + req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":")); + req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + req.onreadystatechange = function() { + if (this.readyState == 4 && this.status != 200) { + thisEl.checked = !thisEl.checked; + } + }; + req.send(JSON.stringify(send)); + }; + middleList.appendChild(notifyCreation); + + dropdownMiddle.appendChild(middleList); + dropdownContent.appendChild(dropdownMiddle); + + {% endif %} + + let dropdownRight = document.createElement('div'); dropdownRight.id = parsedInvite[0] + '_usersCreated'; dropdownRight.classList.add('col'); diff --git a/jellyfin_accounts/data_store.py b/jellyfin_accounts/data_store.py index bcbd925..cf87847 100644 --- a/jellyfin_accounts/data_store.py +++ b/jellyfin_accounts/data_store.py @@ -43,7 +43,10 @@ class JSONFile(dict): def __delitem__(self, key): data = self.readJSON(self.path) super(JSONFile, self).__init__(data) - del data[key] + try: + del data[key] + except KeyError: + pass self.writeJSON(self.path, data) super(JSONFile, self).__delitem__(key) diff --git a/jellyfin_accounts/email.py b/jellyfin_accounts/email.py index 9093194..e47bd38 100644 --- a/jellyfin_accounts/email.py +++ b/jellyfin_accounts/email.py @@ -13,6 +13,15 @@ from jellyfin_accounts import config from jellyfin_accounts import email_log as log +def format_datetime(dt): + result = dt.strftime(config["email"]["date_format"]) + if config.getboolean("email", "use_24h"): + result += f' {dt.strftime("%H:%M")}' + else: + result += f' {dt.strftime("%I:%M %p")}' + return result + + class Email: def __init__(self, address): self.address = address @@ -75,6 +84,47 @@ class Email: self.content[key] = c log.info(f"{self.address}: {key} constructed") + def construct_expiry(self, invite): + self.subject = "Notice: Invite expired" + log.debug(f'Constructing expiry notification for {invite["code"]}') + expiry = format_datetime(invite["expiry"]) + for key in ["text", "html"]: + sp = Path(config["notifications"]["expiry_" + key]) / ".." + sp = str(sp.resolve()) + "/" + template_loader = FileSystemLoader(searchpath=sp) + template_env = Environment(loader=template_loader) + fname = Path(config["notifications"]["expiry_" + key]).name + template = template_env.get_template(fname) + c = template.render(code=invite["code"], expiry=expiry) + self.content[key] = c + log.info(f"{self.address}: {key} constructed") + return True + + def construct_created(self, invite): + self.subject = "Notice: User created" + log.debug(f'Constructing expiry notification for {invite["code"]}') + created = format_datetime(invite["created"]) + if config.getboolean("email", "no_username"): + email = "n/a" + else: + email = invite["address"] + for key in ["text", "html"]: + sp = Path(config["notifications"]["created_" + key]) / ".." + sp = str(sp.resolve()) + "/" + template_loader = FileSystemLoader(searchpath=sp) + template_env = Environment(loader=template_loader) + fname = Path(config["notifications"]["created_" + key]).name + template = template_env.get_template(fname) + c = template.render( + code=invite["code"], + username=invite["username"], + address=email, + time=created, + ) + self.content[key] = c + log.info(f"{self.address}: {key} constructed") + return True + def construct_reset(self, reset): self.subject = config["password_resets"]["subject"] log.debug(f"{self.address}: Using subject {self.subject}") diff --git a/jellyfin_accounts/invite_daemon.py b/jellyfin_accounts/invite_daemon.py new file mode 100644 index 0000000..504c335 --- /dev/null +++ b/jellyfin_accounts/invite_daemon.py @@ -0,0 +1,42 @@ +from threading import Timer +import time +from jellyfin_accounts import config, data_store +from jellyfin_accounts.web_api import checkInvite + + +class Repeat: + def __init__(self, interval, function, *args, **kwargs): + self._timer = None + self.interval = interval + self.function = function + self.args = args + self.kwargs = kwargs + self.is_running = False + self.next_call = time.time() + self.start() + + def _run(self): + self.is_running = False + self.start() + self.function(*self.args, **self.kwargs) + + def start(self): + if not self.is_running: + self.next_call += self.interval + self._timer = Timer(self.next_call - time.time(), self._run) + self._timer.start() + self.is_running = True + + def stop(self): + self._timer.cancel() + self.is_running = False + + +def checkInvites(): + invites = dict(data_store.invites) + # checkInvite already loops over everything, no point running it multiple times. + checkInvite(list(invites.keys())[0]) + + +if config.getboolean("notifications", "enabled"): + inviteDaemon = Repeat(60, checkInvites) diff --git a/jellyfin_accounts/login.py b/jellyfin_accounts/login.py index a17c913..c96fd3b 100644 --- a/jellyfin_accounts/login.py +++ b/jellyfin_accounts/login.py @@ -106,6 +106,8 @@ def verify_password(username, password): user = Account().verify_token(username, accounts) if user: verified = True + if user in accounts: + user = accounts[user] if not user: log.debug(f"User {username} not found on Jellyfin") return False @@ -116,10 +118,10 @@ def verify_password(username, password): if username == user.username and user.verify_password(password): g.user = user log.debug("HTTPAuth Allowed") - return True + return user else: log.debug("HTTPAuth Denied") return False g.user = user log.debug("HTTPAuth Allowed") - return True + return user diff --git a/jellyfin_accounts/web.py b/jellyfin_accounts/web.py index 51d5c21..e41e9e4 100644 --- a/jellyfin_accounts/web.py +++ b/jellyfin_accounts/web.py @@ -43,7 +43,12 @@ def static_proxy(path): if "html" not in path: if "admin.js" in path: return ( - render_template("admin.js", bsVersion=bsVersion(), css_file=css_file), + render_template( + "admin.js", + bsVersion=bsVersion(), + css_file=css_file, + notifications=config.getboolean("notifications", "enabled"), + ), 200, {"Content-Type": "text/javascript"}, ) diff --git a/jellyfin_accounts/web_api.py b/jellyfin_accounts/web_api.py index 9041a31..1a18326 100644 --- a/jellyfin_accounts/web_api.py +++ b/jellyfin_accounts/web_api.py @@ -15,6 +15,7 @@ from jellyfin_accounts import ( configparser, config_base_path, ) +from jellyfin_accounts.email import Mailgun, Smtp from jellyfin_accounts import web_log as log from jellyfin_accounts.validate_password import PasswordValidator @@ -45,6 +46,22 @@ def checkInvite(code, used=False, username=None): "no-limit" not in invites[invite] and invites[invite]["remaining-uses"] < 1 ): log.debug(f"Housekeeping: Deleting expired invite {invite}") + if ( + config.getboolean("notifications", "enabled") + and "notify" in invites[invite] + ): + for address in invites[invite]["notify"]: + if "notify-expiry" in invites[invite]["notify"][address]: + if invites[invite]["notify"][address]["notify-expiry"]: + method = config["email"]["method"] + if method == "mailgun": + email = Mailgun(address) + elif method == "smtp": + email = Smtp(address) + if email.construct_expiry( + {"code": invite, "expiry": expiry} + ): + email.send() del data_store.invites[invite] elif invite == code: match = True @@ -184,7 +201,28 @@ def newUser(): return jsonify({"error": error}) except: return jsonify({"error": "Unknown error"}) + invites = dict(data_store.invites) checkInvite(data["code"], used=True, username=data["username"]) + if ( + config.getboolean("notifications", "enabled") + and "notify" in invites[data["code"]] + ): + for address in invites[data["code"]]["notify"]: + if "notify-creation" in invites[data["code"]]["notify"][address]: + if invites[data["code"]]["notify"][address]["notify-creation"]: + method = config["email"]["method"] + if method == "mailgun": + email = Mailgun(address) + elif method == "smtp": + email = Smtp(address) + if email.construct_created( + { + "code": data["code"], + "username": data["username"], + "created": datetime.datetime.now(), + } + ): + email.send() if user.status_code == 200: try: policy = data_store.user_template @@ -258,6 +296,11 @@ def generateInvite(): response = email.send() if response is False or type(response) != bool: invite["email"] = f"Failed to send to {address}" + if config.getboolean("notifications", "enabled"): + if "notify-creation" in data: + invite["notify-creation"] = data["notify-creation"] + if "notify-expiry" in data: + invite["notify-expiry"] = data["notify-expiry"] data_store.invites[invite_code] = invite log.info(f"New invite created: {invite_code}") return resp() @@ -296,6 +339,20 @@ def getInvites(): invite["remaining-uses"] = 1 if "email" in invites[code]: invite["email"] = invites[code]["email"] + if "notify" in invites[code]: + if config.getboolean("ui", "jellyfin_login"): + address = data_store.emails[g.user.id] + else: + address = config["ui"]["email"] + if address in invites[code]["notify"]: + if "notify-expiry" in invites[code]["notify"][address]: + invite["notify-expiry"] = invites[code]["notify"][address][ + "notify-expiry" + ] + if "notify-creation" in invites[code]["notify"][address]: + invite["notify-creation"] = invites[code]["notify"][address][ + "notify-creation" + ] response["invites"].append(invite) return jsonify(response) @@ -407,3 +464,29 @@ def getConfig(): if entry in config[section]: response_config[section][entry]["value"] = config[section][entry] return jsonify(response_config), 200 + + +@app.route("/setNotify", methods=["POST"]) +@auth.login_required +def setNotify(): + data = request.get_json() + change = False + for code in data: + for key in data[code]: + if key in ["notify-expiry", "notify-creation"]: + inv = data_store.invites[code] + if config.getboolean("ui", "jellyfin_login"): + address = data_store.emails[g.user.id] + else: + address = config["ui"]["email"] + if "notify" not in inv: + inv["notify"] = {} + if address not in inv["notify"]: + inv["notify"][address] = {} + inv["notify"][address][key] = data[code][key] + log.debug(f"{code}: Notification settings changed") + change = True + if change: + data_store.invites[code] = inv + return resp() + return resp(success=False) diff --git a/mail/created.mjml b/mail/created.mjml new file mode 100644 index 0000000..0da22f3 --- /dev/null +++ b/mail/created.mjml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + jellyfin-accounts + + + + + +

User Created

+

A user was created using code {{ code }}.

+
+ + + Name + Address + Time + + + {{ username }} + {{ address }} + {{ time }} + +
+
+ + + + Notification emails can be toggled on the admin dashboard. + + + + +
\ No newline at end of file diff --git a/mail/created.txt b/mail/created.txt new file mode 100644 index 0000000..154fbe0 --- /dev/null +++ b/mail/created.txt @@ -0,0 +1,7 @@ +A user was created using code {{ code }}. + +Name: {{ username }} +Address: {{ address }} +Time: {{ time }} + +Note: Notification emails can be toggled on the admin dashboard. diff --git a/mail/expired.mjml b/mail/expired.mjml new file mode 100644 index 0000000..d0012d5 --- /dev/null +++ b/mail/expired.mjml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + jellyfin-accounts + + + + + +

Invite Expired.

+

Code {{ code }} expired at {{ expiry }}.

+
+
+
+ + + + Notification emails can be toggled on the admin dashboard. + + + + +
diff --git a/mail/expired.txt b/mail/expired.txt new file mode 100644 index 0000000..3541d16 --- /dev/null +++ b/mail/expired.txt @@ -0,0 +1,5 @@ +Invite expired. + +Code {{ code }} expired at {{ expiry }}. + +Note: Notification emails can be toggled on the admin dashboard.