38 Commits

Author SHA1 Message Date
237c575441 make jfa-go mention bigger
you should try it out.
2020-08-18 14:34:26 +01:00
9185b59d16 Dockerfile builds from source
Uses multi-stage build, like jfa-go. I'm hoping this'll shrink the image
aswell.
2020-08-17 12:07:28 +01:00
43c0631f9b add non interactive modes to build scripts 2020-08-17 11:45:42 +01:00
3d10a8fe06 fix email generation on windows 2020-08-04 18:14:39 +01:00
8d265879cc actually fixed windows scss build 2020-08-04 18:12:18 +01:00
a38045cefb potential fixes for windows
hopefully fixes scss and email generation on windows by fixing runcmd()
and (optionally) reading npm bin location from the 'npm bin' command.
also, config path is cast to string before being passed to configparser.
2020-08-04 01:29:29 +01:00
d5609f3870 update readme, fix setup.js setting toggle 2020-08-02 02:33:34 +01:00
f2966ef810 Fix new user defaults and bump to 0.3.9 bcs im dumb
Pushed 0.3.8 to PyPI and Docker hub thinking i'd already fixed a problem
with new user defaults. I hadn't.
2020-07-24 12:04:57 +01:00
2e20466925 Self-restarting for config changes
When changing settings that need restart, the option is now available to
do it automatically. Functions on linux at least, might need testing on
    windows.
2020-07-20 15:37:19 +01:00
ef8ff531e3 Don't check invites if there aren't any
self-explanatory. Check if the dict is empty before doing anything.
2020-07-18 18:21:36 +01:00
b863706d26 Thread notification emails to avoid slowing UI 2020-07-18 18:20:54 +01:00
7ec8650467 Mention that expiry time is UTC 2020-07-18 18:19:14 +01:00
d5ce6d31c5 Handle FileNotFoundError
I'm guessing watchdog's behaviour changed in an update, as the error
thrown when the watched directory doesn't exist is now
"FileNotFoundError" instead of "NotADirectoryError". It'll handle either
one now.
2020-07-18 18:15:01 +01:00
95989840f1 Load templates directly, Account for daylight savings time
password reset files use UTC always, which I did not realize when
writing the password reset handler as the UK uses UTC and we weren't in
daylight savings time. The expiry time is now correctly handled as UTC.

An environment is no longer initialised for every email construction,
instead the templates are loaded directly.
2020-07-18 18:11:00 +01:00
658f660e19 update gif 2020-07-17 19:20:55 +01:00
b5af2e7f9d Bump to 0.3.7 2020-07-17 17:35:24 +01:00
dea613fa85 Add notifications setting to setup page 2020-07-17 16:34:37 +01:00
b8fdb64f68 Added per-invite notifications for expiry and user creation
Notifications must be enabled in settings; they can then be
toggled in the dropdown menu of each invite.
2020-07-17 16:08:36 +01:00
e80b233af2 Email generation as part of build process
Moved email source to separate directory, added the task
"generate-emails" to create html files.
2020-07-16 19:05:56 +01:00
3e53bcab27 Update vulnerable lodash dep per dependabot recommendation. 2020-07-15 23:37:36 +01:00
2551307877 Redesigned emails
Emails now use the same colorscheme as the rest of the ui.
2020-07-15 23:33:58 +01:00
290e6b3dca switch to main over master 2020-07-13 00:18:42 +01:00
a49b4d9027 Merge branch 'master' of github.com:hrfee/jellyfin-accounts
Forgot to pull after adding issue template
2020-07-12 19:56:17 +01:00
d615b21c7d 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.
2020-07-12 19:53:04 +01:00
9afbd31faa Add bug report template 2020-07-11 16:56:21 +01:00
27169e4e0d Added dropdown menu for invites, multi-use invites, bump to 0.3.6
Dropdown menu includes time created, and for multi-use invites,
remaining uses, as well as a list of usernames created using the code.
2020-07-10 16:15:17 +01:00
db3b992857 refactor admin.js, add initial ui elements for multi-use invites
multi-use invites will have a set limit of how many times they can be
used. They can also be set to have no limit. An additional menu is
planned for multi use invites to see when they have been used, and by
who.
2020-07-09 23:05:01 +01:00
89c132e92e bump to 0.3.5 2020-07-09 20:34:31 +01:00
7bda2f4141 Fix UI error when 'send to address' option disabled 2020-07-09 20:33:14 +01:00
71f05f2348 Replace jquery ajax in setup.js 2020-07-07 20:10:27 +01:00
94e69ad090 Small CSS tweaks, add days input 2020-07-07 15:30:16 +01:00
a3d3d97b3b Added theme toggle to Admin page
The admin can switch between the two default themes without a page
reload, with a nice animation (on small screens). Preference is stored
as a cookie, so the default theme setting will still apply to others.
2020-07-06 20:53:14 +01:00
781306f1ef Automation of CSS compilation, fixed .gitignore build issue
The grabbing of dependencies and compilation of SCSS can now simply be
done with a:

poetry run task compile-css

before a:

poetry build

When building from source. The issue where the .gitignore had to be
removed before building has been fixed, too.
2020-07-06 15:04:28 +01:00
a62eab9565 bump to 0.3.2 due to packaging errors 2020-07-05 21:50:52 +01:00
a2a2abc7f2 fix mime type for admin.js 2020-07-05 21:39:58 +01:00
fa0527c6a7 Remove all css
Removed css because I don't want the "Languages" section to show 90%
CSS. Build instructions will be updated with how to build CSS yourself.
2020-07-05 16:42:39 +01:00
b33922059c remove extra css 2020-07-05 16:39:48 +01:00
9da3832e3a Merge pull request #28 from hrfee/bs5
Add themes, Bootstrap 5 support, bump to 0.3.0
2020-07-05 16:34:32 +01:00
60 changed files with 2722 additions and 30519 deletions

25
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,25 @@
---
name: Bug report
about: Template for bug reports.
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
Describe the problem, and what you would expect if it isn't clear already.
**To Reproduce**
What to do to reproduce the problem.
**Logs**
When you notice the problem, check the output of `jf-accounts`. If the problem is not obvious (e.g an expection or 'ERROR' log), try enabling `debug` in your configuration's `[ui]` section, restarting and reproducing the problem. You should then take a screenshot of the output, or paste it here, preferably between \`\`\` tags (e.g \`\`\``Log here`\`\`\`).
If nothing catches your eye in the log, access the admin page via your browser, go into the console (Right click > Inspect Element > Console), refresh, then paste the output here in the same way as above.
**Platform**
Include the platform jf-accounts is running on (e.g Windows, Linux, Docker), the python version, and if necessary the browser version and platform.

7
.gitignore vendored
View File

@@ -12,8 +12,13 @@ jfa/
colors.txt colors.txt
theme.css theme.css
jellyfin_accounts/__pycache__/ jellyfin_accounts/__pycache__/
jellyfin_accounts/data/static/*.css
old/ old/
.jf-accounts/ .jf-accounts/
requirements.txt requirements.txt
package-lock.json
video/ video/
scss/bs5/*.css*
scss/bs4/*.css*
mail/*.html
jellyfin_accounts/data/*.html
jellyfin_accounts/data/*.txt

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.8.2-buster AS build
COPY . /opt/build
RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash -
RUN cd /opt/build \
&& rm -rf dist \
&& apt install nodejs \
&& ~/.poetry/bin/poetry update \
&& pip install libsass \
&& python scss/get_node_deps.py \
&& python scss/compile.py -y \
&& python mail/generate.py -y \
&& ~/.poetry/bin/poetry build -f wheel
FROM python:3.8.2-buster
COPY --from=build /opt/build/dist /opt/dist
RUN pip install /opt/dist/*.whl
RUN sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /usr/local/lib/python3.8/site-packages/jellyfin_accounts/data/templates/setup.html
CMD [ "python3.8", "/usr/local/bin/jf-accounts", "-d", "/data" ]

View File

@@ -1,5 +1,9 @@
### 👀 ➡️: Have a look at [jfa-go](https://github.com/hrfee/jfa-go), a rewrite in Go. Identical look and features but might be faster.
this version likely won't get too many more updates, so i recommend you switch.
# ![jellyfin-accounts](https://raw.githubusercontent.com/hrfee/jellyfin-accounts/bs5/images/jellyfin-accounts-banner-wide.svg) # ![jellyfin-accounts](https://raw.githubusercontent.com/hrfee/jellyfin-accounts/bs5/images/jellyfin-accounts-banner-wide.svg)
A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin). A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin).
* Provides a web interface for creating/sending invites * Provides a web interface for creating/sending invites
* Sends out emails when a user requests a password reset * Sends out emails when a user requests a password reset
@@ -9,12 +13,12 @@ A basic account management system for [Jellyfin](https://github.com/jellyfin/jel
* Password resets are handled using smtplib, requests, and [jinja](https://github.com/pallets/jinja) * Password resets are handled using smtplib, requests, and [jinja](https://github.com/pallets/jinja)
## Interface ## Interface
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/jfa.gif" width="100%"></img> <img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/jfa.gif" width="100%"></img>
</p> </p>
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/admin.png" width="48%" style="margin-right: 1.5%;" alt="Admin page"></img> <img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/admin.png" width="48%" style="margin-right: 1.5%;" alt="Admin page"></img>
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/create.png" width="48%" style="margin-left: 1.5%;" alt="Account creation page"></img> <img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/create.png" width="48%" style="margin-left: 1.5%;" alt="Account creation page"></img>
</p> </p>

View File

@@ -9,13 +9,13 @@ 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.0 version = 0.3.7
device = jf-accounts device = jf-accounts
device_id = jf-accounts-0.3.0 device_id = jf-accounts-0.3.7
[ui] [ui]
; settings related to the ui and program functionality. ; settings related to the ui and program functionality.
; choose the look of jellyfin-accounts. ; default appearance for all users.
theme = Jellyfin (Dark) theme = Jellyfin (Dark)
; set 0.0.0.0 to run on localhost ; set 0.0.0.0 to run on localhost
host = 0.0.0.0 host = 0.0.0.0
@@ -28,10 +28,12 @@ admin_only = true
username = your username username = your username
; password for admin page (leave blank if using jellyfin_login) ; password for admin page (leave blank if using jellyfin_login)
password = your password password = your password
; address to send notifications to (leave blank if using jellyfin_login)
email = example@example.com
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.
@@ -88,6 +90,19 @@ subject = Invite - Jellyfin
; base url for jf-accounts. this is necessary because using a reverse proxy means the program has no way of knowing the url itself. ; base url for jf-accounts. this is necessary because using a reverse proxy means the program has no way of knowing the url itself.
url_base = http://accounts.jellyf.in:8056/invite url_base = http://accounts.jellyf.in:8056/invite
[notifications]
; notification related settings.
; enabling adds optional toggles to invites to notify on expiry and user creation.
enabled = true
; path to expiry notification email html.
expiry_html =
; path to expiry notification email in plaintext.
expiry_text =
; path to user creation notification email html.
created_html =
; path to user creation notification email in plaintext.
created_text =
[mailgun] [mailgun]
; mailgun api connection settings ; mailgun api connection settings
api_url = https://api.mailgun.net... api_url = https://api.mailgun.net...

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 # Runs it!
__version__ = "0.3.0" __version__ = "0.3.9"
import secrets import secrets
import configparser import configparser
@@ -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,9 +70,9 @@ 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(str(config_path.resolve()))
def create_log(name): def create_log(name):
@@ -93,65 +94,12 @@ 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:
email_log = create_log("emails") email_log = create_log("email")
pwr_log = create_log("pwr")
auth_log = create_log("auth") auth_log = create_log("auth")
if args.host is not None: if args.host is not None:
@@ -218,7 +166,7 @@ elif "Custom" in current_theme and "custom_css" in config["files"]:
try: try:
css_path = Path(config["files"]["custom_css"]) css_path = Path(config["files"]["custom_css"])
shutil.copy(css_path, (local_dir / "static" / css_path.name)) shutil.copy(css_path, (local_dir / "static" / css_path.name))
log.debug('Loaded custom CSS "{css_path.name}"') log.debug(f'Loaded custom CSS "{css_path.name}"')
css_file = css_path.name css_file = css_path.name
except FileNotFoundError: except FileNotFoundError:
log.error( log.error(
@@ -238,6 +186,7 @@ def resp(success=True, code=500):
r.status_code = code r.status_code = code
return r return r
app = Flask(__name__, root_path=str(local_dir))
def main(): def main():
if args.install: if args.install:
@@ -343,15 +292,6 @@ def main():
success = True success = True
else: 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") app.config["DEBUG"] = config.getboolean("ui", "debug")
app.config["SECRET_KEY"] = secrets.token_urlsafe(16) app.config["SECRET_KEY"] = secrets.token_urlsafe(16)
app.config["JSON_SORT_KEYS"] = False app.config["JSON_SORT_KEYS"] = False
@@ -359,6 +299,13 @@ def main():
from waitress import serve from waitress import serve
if first_run: 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 import jellyfin_accounts.setup
host = config["ui"]["host"] host = config["ui"]["host"]
@@ -368,6 +315,7 @@ def main():
else: else:
import jellyfin_accounts.web_api import jellyfin_accounts.web_api
import jellyfin_accounts.web import jellyfin_accounts.web
import jellyfin_accounts.invite_daemon
host = config["ui"]["host"] host = config["ui"]["host"]
port = config["ui"]["port"] port = config["ui"]["port"]
@@ -380,7 +328,15 @@ def main():
jellyfin_accounts.pw_reset.start() jellyfin_accounts.pw_reset.start()
pwr = threading.Thread(target=start_pwr, daemon=True) pwr = threading.Thread(target=start_pwr, daemon=True)
log.info("Starting email thread") log.info("Starting password reset thread")
pwr.start() 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)) serve(app, host=host, port=int(port))

121
jellyfin_accounts/config.py Normal file
View File

@@ -0,0 +1,121 @@
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):
# Lord forgive me for this mess
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"
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):
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

@@ -71,7 +71,7 @@
"description": "Settings related to the UI and program functionality." "description": "Settings related to the UI and program functionality."
}, },
"theme": { "theme": {
"name": "Look", "name": "Default Look",
"required": false, "required": false,
"requires_restart": true, "requires_restart": true,
"type": "select", "type": "select",
@@ -81,7 +81,7 @@
"Custom CSS" "Custom CSS"
], ],
"value": "Jellyfin (Dark)", "value": "Jellyfin (Dark)",
"description": "Choose the look of jellyfin-accounts." "description": "Default appearance for all users."
}, },
"host": { "host": {
"name": "Address", "name": "Address",
@@ -133,6 +133,15 @@
"value": "your password", "value": "your password",
"description": "Password for admin page (Leave blank if using jellyfin_login)" "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": { "debug": {
"name": "Debug logging", "name": "Debug logging",
"required": false, "required": false,
@@ -143,7 +152,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 +160,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 +176,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 +190,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 +238,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 +359,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
}, },
@@ -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." "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": { "mailgun": {
"meta": { "meta": {
"name": "Mailgun (Email)", "name": "Mailgun (Email)",

View File

@@ -1,242 +0,0 @@
<!-- FILE: email.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style type="text/css">
</style>
</head>
<body>
<div style="">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Noto Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1;text-align:left;color:#000000;">
<p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p>
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
<p>If this wasn't you, please ignore this email.</p>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
<p style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
{{ pin }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>

View File

@@ -1,34 +0,0 @@
<mjml>
<mj-head>
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="bold" font-size="20px">
Jellyfin
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="15px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p>
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
<p>If this wasn't you, please ignore this email.</p>
</mj-text>
<mj-button>{{ pin }}</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="italic" font-size="10px">
{{ message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

@@ -1,239 +0,0 @@
<!-- FILE: invite-email.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style type="text/css">
</style>
</head>
<body>
<div style="">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Noto Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1;text-align:left;color:#000000;">
<p>Hi,</p>
<h2>You've been invited to Jellyfin.</h2>
<p>To join, click the button below.</p>
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
<a href="{{ invite_link }}" style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Setup your account </a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>

View File

@@ -1,33 +0,0 @@
<mjml>
<mj-head>
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="bold" font-size="20px">
Jellyfin
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="15px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi,</p>
<h2>You've been invited to Jellyfin.</h2>
<p>To join, click the button below.</p>
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
</mj-text>
<mj-button href="{{ invite_link }}">Setup your account</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="italic" font-size="10px">
{{ message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,6 +16,7 @@ function serializeForm(id) {
case 'password': case 'password':
case 'select-one': case 'select-one':
case 'email': case 'email':
case 'number':
formData[name] = el.value; formData[name] = el.value;
break; break;
}; };

View File

@@ -13,20 +13,27 @@ for (var i = 0; i < authRadios.length; i++) {
checkAuthRadio(); checkAuthRadio();
}); });
}; };
function checkEmailRadio() { function checkEmailRadio() {
document.getElementById('emailNextButton').href = '#page-5'; document.getElementById('emailNextButton').href = '#page-5';
document.getElementById('valBackButton').href = '#page-7'; document.getElementById('valBackButton').href = '#page-7';
if (document.getElementById('emailSMTPRadio').checked) { if (document.getElementById('emailSMTPRadio').checked) {
document.getElementById('emailCommonArea').style.display = '';
document.getElementById('emailSMTPArea').style.display = ''; document.getElementById('emailSMTPArea').style.display = '';
document.getElementById('emailMailgunArea').style.display = 'none'; document.getElementById('emailMailgunArea').style.display = 'none';
document.getElementById('notificationsEnabled').checked = true;
} else if (document.getElementById('emailMailgunRadio').checked) { } else if (document.getElementById('emailMailgunRadio').checked) {
document.getElementById('emailCommonArea').style.display = '';
document.getElementById('emailSMTPArea').style.display = 'none'; document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = ''; document.getElementById('emailMailgunArea').style.display = '';
document.getElementById('notificationsEnabled').checked = true;
} else if (document.getElementById('emailDisabledRadio').checked) { } else if (document.getElementById('emailDisabledRadio').checked) {
document.getElementById('emailCommonArea').style.display = 'none';
document.getElementById('emailSMTPArea').style.display = 'none'; document.getElementById('emailSMTPArea').style.display = 'none';
document.getElementById('emailMailgunArea').style.display = 'none'; document.getElementById('emailMailgunArea').style.display = 'none';
document.getElementById('emailNextButton').href = '#page-8'; document.getElementById('emailNextButton').href = '#page-8';
document.getElementById('valBackButton').href = '#page-4'; document.getElementById('valBackButton').href = '#page-4';
document.getElementById('notificationsEnabled').checked = false;
}; };
}; };
var emailRadios = ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio']; var emailRadios = ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio'];
@@ -35,6 +42,7 @@ for (var i = 0; i < emailRadios.length; i++) {
checkEmailRadio(); checkEmailRadio();
}); });
}; };
function checkSSL() { function checkSSL() {
var label = document.getElementById('emailSSL_TLSLabel'); var label = document.getElementById('emailSSL_TLSLabel');
if (document.getElementById('emailSSL_TLS').checked) { if (document.getElementById('emailSSL_TLS').checked) {
@@ -101,16 +109,15 @@ document.getElementById('jfTestButton').onclick = function() {
jfData['jfHost'] = document.getElementById('jfHost').value; jfData['jfHost'] = document.getElementById('jfHost').value;
jfData['jfUser'] = document.getElementById('jfUser').value; jfData['jfUser'] = document.getElementById('jfUser').value;
jfData['jfPassword'] = document.getElementById('jfPassword').value; jfData['jfPassword'] = document.getElementById('jfPassword').value;
$.ajax('/testJF', { var req = new XMLHttpRequest();
type : 'POST', req.open("POST", "/testJF", true);
dataType : 'json', req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
contentType : 'application/json', req.responseType = 'json';
data : JSON.stringify(jfData), req.onreadystatechange = function() {
complete: function(response) { if (this.readyState == 4) {
testButton.disabled = false; testButton.disabled = false;
testButton.className = ''; testButton.className = '';
var success = response['responseJSON']['success']; if (this.response['success'] == true) {
if (success == true) {
testButton.classList.add('btn', 'btn-success'); testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success'; testButton.textContent = 'Success';
nextButton.classList.remove('disabled'); nextButton.classList.remove('disabled');
@@ -118,9 +125,10 @@ document.getElementById('jfTestButton').onclick = function() {
} else { } else {
testButton.classList.add('btn', 'btn-danger'); testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed'; testButton.textContent = 'Failed';
} };
} };
}); };
req.send(JSON.stringify(jfData));
}; };
document.getElementById('submitButton').onclick = function() { document.getElementById('submitButton').onclick = function() {
@@ -138,6 +146,7 @@ document.getElementById('submitButton').onclick = function() {
config['invite_emails'] = {}; config['invite_emails'] = {};
config['mailgun'] = {}; config['mailgun'] = {};
config['smtp'] = {}; config['smtp'] = {};
config['notifications'] = {};
// Page 2: Auth // Page 2: Auth
if (document.getElementById('jfAuthRadio').checked) { if (document.getElementById('jfAuthRadio').checked) {
config['ui']['jellyfin_login'] = 'true'; config['ui']['jellyfin_login'] = 'true';
@@ -149,6 +158,7 @@ document.getElementById('submitButton').onclick = function() {
} else { } else {
config['ui']['username'] = document.getElementById('manualAuthUsername').value; config['ui']['username'] = document.getElementById('manualAuthUsername').value;
config['ui']['password'] = document.getElementById('manualAuthPassword').value; config['ui']['password'] = document.getElementById('manualAuthPassword').value;
config['ui']['email'] = document.getElementById('manualAuthEmail').value;
}; };
// Page 3: Connect to jellyfin // Page 3: Connect to jellyfin
config['jellyfin']['server'] = document.getElementById('jfHost').value; config['jellyfin']['server'] = document.getElementById('jfHost').value;
@@ -158,7 +168,8 @@ document.getElementById('submitButton').onclick = function() {
if (document.getElementById('emailDisabledRadio').checked) { if (document.getElementById('emailDisabledRadio').checked) {
config['password_resets']['enabled'] = 'false'; config['password_resets']['enabled'] = 'false';
config['invite_emails']['enabled'] = 'false'; config['invite_emails']['enabled'] = 'false';
} else { config['notificatons']['enabled'] = 'false';
} else {
if (document.getElementById('emailSMTPRadio').checked) { if (document.getElementById('emailSMTPRadio').checked) {
if (document.getElementById('emailSSL_TLS').checked) { if (document.getElementById('emailSSL_TLS').checked) {
config['smtp']['encryption'] = 'ssl_tls'; config['smtp']['encryption'] = 'ssl_tls';
@@ -176,6 +187,7 @@ document.getElementById('submitButton').onclick = function() {
config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value; config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value;
config['email']['address'] = document.getElementById('emailMailgunAddress').value; config['email']['address'] = document.getElementById('emailMailgunAddress').value;
}; };
config['notifications']['enabled'] = document.getElementById('notificationsEnabled').checked.toString();
// Page 5: Email formatting // Page 5: Email formatting
config['email']['from'] = document.getElementById('emailSender').value; config['email']['from'] = document.getElementById('emailSender').value;
config['email']['date_format'] = document.getElementById('emailDateFormat').value; config['email']['date_format'] = document.getElementById('emailDateFormat').value;
@@ -211,24 +223,24 @@ document.getElementById('submitButton').onclick = function() {
config['password_validation']['number'] = document.getElementById('valNumber').value; config['password_validation']['number'] = document.getElementById('valNumber').value;
config['password_validation']['special'] = document.getElementById('valSpecial').value; config['password_validation']['special'] = document.getElementById('valSpecial').value;
} else { } else {
config['password_validation']['enabled'] = 'false'; config['password_validation']['enabled'] = 'false';
}; };
// Page 9: Messages // Page 9: Messages
config['ui']['contact_message'] = document.getElementById('msgContact').value; config['ui']['contact_message'] = document.getElementById('msgContact').value;
config['ui']['help_message'] = document.getElementById('msgHelp').value; config['ui']['help_message'] = document.getElementById('msgHelp').value;
config['ui']['success_message'] = document.getElementById('msgSuccess').value; config['ui']['success_message'] = document.getElementById('msgSuccess').value;
console.log(config); // Send it
$.ajax('/modifyConfig', { var req = new XMLHttpRequest();
type : 'POST', req.open("POST", "/modifyConfig", true);
dataType : 'json', req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
contentType : 'application/json', req.responseType = 'json';
data : JSON.stringify(config), req.onreadystatechange = function() {
complete: function(response) { if (this.readyState == 4) {
submitButton.disabled = false; submitButton.disabled = false;
submitButton.className = ''; submitButton.className = '';
submitButton.classList.add('btn', 'btn-success'); submitButton.classList.add('btn', 'btn-success');
submitButton.textContent = 'Success'; submitButton.textContent = 'Success';
} };
}); };
req.send(JSON.stringify(config));
}; };

View File

@@ -14,7 +14,38 @@
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" href="{{ css_file }}"> <script>
function getCookie(cname) {
var name = cname + "=";
var decodedCookie = decodeURIComponent(document.cookie);
var ca = decodedCookie.split(';');
for(var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
};
{% if bs5 %}
const bsVersion = 5;
{% else %}
const bsVersion = 4;
{% endif %}
var css = document.createElement('link');
css.setAttribute('rel', 'stylesheet');
css.setAttribute('type', 'text/css');
var cssCookie = getCookie("css");
if (cssCookie.includes('bs' + bsVersion)) {
css.setAttribute('href', cssCookie);
} else {
css.setAttribute('href', '{{ css_file }}');
};
document.head.appendChild(css);
</script>
{% if not bs5 %} {% if not bs5 %}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{% endif %} {% endif %}
@@ -53,10 +84,42 @@
margin-top: 5%; margin-top: 5%;
color: grey; color: grey;
} }
.circle {
/*margin-left: 1rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
z-index: 5000;*/
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
}
.smooth-transition {
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeincubic */
}
.rotated {
transform: rotate(180deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
.not-rotated {
transform: rotate(0deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
</style> </style>
<title>Admin</title> <title>Admin</title>
</head> </head>
<body> <body class="smooth-transition">
<div class="modal fade" id="login" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static"> <div class="modal fade" id="login" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document"> <div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
@@ -151,18 +214,31 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="restartModal" role="dialog" aria-labelledby"Restart Warning" aria-hidden="true"> <div class="modal fade" id="restartModal" role="dialog" aria-labelledby="Restart Warning" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document"> <div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Warning</h5> <h5 class="modal-title">Warning</h5>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>A restart is needed to apply some settings. This must be done manually. Apply now?</p> <p>A restart is needed to apply some settings. Restart now, later, or cancel?</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button> <button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="applyRestarts" data-dismiss="alert">Apply</button> <button type="button" class="btn btn-secondary" id="applyRestarts" data-dismiss="modal">Apply, Restart later</button>
<button type="button" class="btn btn-primary" id="applyAndRestart" data-dismiss="modal">Apply &amp; Restart</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="refreshModal" role="dialog" aria-labelledby="Refresh page notice" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Settings applied.</h5>
</div>
<div class="modal-body">
<p>Refresh the page in a few seconds.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -171,9 +247,11 @@
<h1> <h1>
Accounts admin Accounts admin
</h1> </h1>
<button type="button" class="btn btn-secondary" id="openSettings"> <div class="btn-group" role="group" id="headerButtons">
Settings <i class="fa fa-cog"></i> <button type="button" class="btn btn-primary" id="openSettings">
</button> Settings <i class="fa fa-cog"></i>
</button>
</div>
<div class="card mb-3 linkGroup"> <div class="card mb-3 linkGroup">
<div class="card-header">Current Invites</div> <div class="card-header">Current Invites</div>
<ul class="list-group list-group-flush" id="invites"> <ul class="list-group list-group-flush" id="invites">
@@ -183,29 +261,66 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header">Generate Invite</div> <div class="card-header">Generate Invite</div>
<div class="card-body"> <div class="card-body">
<form action="#" method="POST" id="inviteForm"> <form action="#" method="POST" id="inviteForm" class="container">
<div class="form-group"> <div class="row align-items-start">
<label for="hours">Hours</label> <div class="col">
<select class="form-control" id="hours" name="hours"> <div class="form-group">
</select> <label for="days">Days</label>
</div> <select class="form-control form-select" id="days" name="days">
<div class="form-group"> </select>
<label for="minutes">Minutes</label> </div>
<select class="form-control" id="minutes" name="minutes"> <div class="form-group">
</select> <label for="hours">Hours</label>
</div> <select class="form-control form-select" id="hours" name="hours">
{% if email_enabled %} </select>
<div class="form-group"> </div>
<label for="send_to_address">Send invite to address</label> <div class="form-group">
<div class="input-group"> <label for="minutes">Minutes</label>
<div class="input-group-text"> <select class="form-control form-select" id="minutes" name="minutes">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled"> </select>
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div> </div>
</div> </div>
{% endif %} <div class="col">
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">Generate</button> <div class="form-group">
<label for="multiUseCount">
Multiple uses
</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('multiUseCount').disabled = !this.checked; document.getElementById('noUseLimit').disabled = !this.checked" aria-label="Checkbox to allow choice of invite usage limit" name="multiple-uses" id="multiUseEnabled">
</div>
<input type="number" class="form-control" name="remaining-uses" id="multiUseCount">
</div>
</div>
<div class="form-group form-check" style="margin-top: 1rem; margin-bottom: 1rem;">
<input class="form-check-input" type="checkbox" value="" name="no-limit" id="noUseLimit" onchange="document.getElementById('multiUseCount').disabled = this.checked; if (this.checked) { document.getElementById('noLimitWarning').style = 'display: block;' } else { document.getElementById('noLimitWarning').style = 'display: none;'; }">
<label class="form-check-label" for="noUseLimit">
No use limit
</label>
<div id="noLimitWarning" class="form-text" style="display: none;">Warning: Unlimited usage invites pose a risk if published online.</div>
</div>
{% if email_enabled %}
<div class="form-group">
<label for="send_to_address">Send invite to address</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div>
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group d-flex float-right">
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">
Generate
</button>
</div>
</div>
</div>
</form> </form>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -156,6 +156,9 @@
submitButton.replaceChild(newSpan, oldSpan); submitButton.replaceChild(newSpan, oldSpan);
}; };
document.getElementById('accountForm').onsubmit = function() { document.getElementById('accountForm').onsubmit = function() {
if (document.getElementById('errorMessage')) {
document.getElementById('errorMessage').remove();
}
toggleSpinner(); toggleSpinner();
var send = serializeForm('accountForm'); var send = serializeForm('accountForm');
send['code'] = code; send['code'] = code;
@@ -171,12 +174,18 @@
if (this.readyState == 4) { if (this.readyState == 4) {
toggleSpinner(); toggleSpinner();
var data = this.response; var data = this.response;
if ('error' in data) { if ('error' in data || data['success'] == false) {
var text = document.createTextNode(data['error']); if (typeof(data['error']) != 'undefined') {
var errorMessage = data['error'];
} else {
var errorMessage = 'Unknown Error';
}
var text = document.createTextNode(errorMessage);
var error = document.createElement('button'); var error = document.createElement('button');
error.classList.add('btn', 'btn-outline-danger'); error.classList.add('btn', 'btn-outline-danger');
error.setAttribute('disabled', ''); error.setAttribute('disabled', '');
error.appendChild(text); error.appendChild(text);
error.id = 'errorMessage';
document.getElementById('errorBox').appendChild(error); document.getElementById('errorBox').appendChild(error);
} else { } else {
var valid = true var valid = true

View File

@@ -89,6 +89,11 @@
<label for="manualAuthPassword">Password</label> <label for="manualAuthPassword">Password</label>
<input type="password" class="form-control" id="manualAuthPassword" placeholder="Password"> <input type="password" class="form-control" id="manualAuthPassword" placeholder="Password">
</div> </div>
<div class="form-group">
<label for="manualAuthEmail">Email (Optional)</label>
<input type="email" class="form-control" id="manualAuthEmail" placeholder="example@example.com">
<small class="form-text text-muted">Your email address is only required if you want to recieve activity notifications.</small>
</div>
</div> </div>
</p> </p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons"> <div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
@@ -180,6 +185,15 @@
<input type="email" class="form-control" id="emailMailgunAddress" placeholder="jellyfin@jellyf.in"> <input type="email" class="form-control" id="emailMailgunAddress" placeholder="jellyfin@jellyf.in">
</div> </div>
</div> </div>
<div id="emailCommonArea">
<h5 class="card-title">Notifications</h5>
<p class="card-text">Enabling notifications will allow you to choose (per-invite) to recieve emails when an invite expires, or when a new user is created. If you chose to use Manual auth instead of Jellyfin auth previously, make sure you provided an email address.</p>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="notificationsEnabled">
<label for="notificationsEnabled" class="form-check-label">Enabled</label>
</div>
</div>
</p> </p>
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons"> <div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
<a class="btn btn-secondary backButton" href="#page-3">Back</a> <a class="btn btn-secondary backButton" href="#page-3">Back</a>
@@ -341,7 +355,7 @@
<div class="card-body text-center"> <div class="card-body text-center">
<h5 class="card-title">Finished!</h5> <h5 class="card-title">Finished!</h5>
<p class="card-text"> <p class="card-text">
Press the button below to submit your settings. The program will quit, so run it again, then refresh this page. Press the button below to submit your settings. The program will restart. Once it's done, refresh this page.
</p> </p>
<button id="submitButton" class="btn btn-primary">Submit</button> <button id="submitButton" class="btn btn-primary">Submit</button>
</div> </div>

View File

@@ -1,3 +1,4 @@
# Automatic storage of everything except the config
import json import json
import datetime import datetime
@@ -42,7 +43,10 @@ class JSONFile(dict):
def __delitem__(self, key): def __delitem__(self, key):
data = self.readJSON(self.path) data = self.readJSON(self.path)
super(JSONFile, self).__init__(data) super(JSONFile, self).__init__(data)
del data[key] try:
del data[key]
except KeyError:
pass
self.writeJSON(self.path, data) self.writeJSON(self.path, data)
super(JSONFile, self).__delitem__(key) super(JSONFile, self).__delitem__(key)

View File

@@ -1,3 +1,4 @@
# Handles everything related to emails
import datetime import datetime
import pytz import pytz
import requests import requests
@@ -7,11 +8,20 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from pathlib import Path from pathlib import Path
from dateutil import parser as date_parser from dateutil import parser as date_parser
from jinja2 import Environment, FileSystemLoader from jinja2 import Template
from jellyfin_accounts import config from jellyfin_accounts import config
from jellyfin_accounts import email_log as log 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: class Email:
def __init__(self, address): def __init__(self, address):
self.address = address self.address = address
@@ -25,9 +35,16 @@ class Email:
+ f"({self.from_name})" + f"({self.from_name})"
) )
) )
# sp = Path(config["invite_emails"]["email_
# template_loader = FileSystemLoader(searchpath=sp)
# template_loader = PackageLoader("jellyfin_accounts", "data")
# self.template_env = Environment(loader=template_loader)
def pretty_time(self, expiry): def pretty_time(self, expiry, tzaware=False):
current_time = datetime.datetime.now() if tzaware:
current_time = datetime.datetime.utcnow().replace(tzinfo=pytz.utc)
else:
current_time = datetime.datetime.now()
date = expiry.strftime(config["email"]["date_format"]) date = expiry.strftime(config["email"]["date_format"])
if config.getboolean("email", "use_24h"): if config.getboolean("email", "use_24h"):
log.debug(f"{self.address}: Using 24h time") log.debug(f"{self.address}: Using 24h time")
@@ -43,9 +60,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}
@@ -60,12 +75,9 @@ class Email:
invite_link = config["invite_emails"]["url_base"] invite_link = config["invite_emails"]["url_base"]
invite_link += "/" + invite["code"] invite_link += "/" + invite["code"]
for key in ["text", "html"]: for key in ["text", "html"]:
sp = Path(config["invite_emails"]["email_" + key]) / ".." fpath = Path(config["invite_emails"]["email_" + key])
sp = str(sp.resolve()) + "/" with open(fpath, 'r') as f:
template_loader = FileSystemLoader(searchpath=sp) template = Template(f.read())
template_env = Environment(loader=template_loader)
fname = Path(config["invite_emails"]["email_" + key]).name
template = template_env.get_template(fname)
c = template.render( c = template.render(
expiry_date=pretty["date"], expiry_date=pretty["date"],
expiry_time=pretty["time"], expiry_time=pretty["time"],
@@ -76,28 +88,59 @@ class Email:
self.content[key] = c self.content[key] = c
log.info(f"{self.address}: {key} constructed") 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"]:
fpath = Path(config["notifications"]["expiry_" + key])
with open(fpath, 'r') as f:
template = Template(f.read())
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 user creation 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"]:
fpath = Path(config["notifications"]["created_" + key])
with open(fpath, 'r') as f:
template = Template(f.read())
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): def construct_reset(self, reset):
self.subject = config["password_resets"]["subject"] self.subject = config["password_resets"]["subject"]
log.debug(f"{self.address}: Using subject {self.subject}") log.debug(f"{self.address}: Using subject {self.subject}")
log.debug(f"{self.address}: Constructing email content") log.debug(f"{self.address}: Constructing email content")
try: try:
expiry = date_parser.parse(reset["ExpirationDate"]) expiry = date_parser.parse(reset["ExpirationDate"])
expiry = expiry.replace(tzinfo=None)
except: except:
log.error(f"{self.address}: Couldn't parse expiry time") log.error(f"{self.address}: Couldn't parse expiry time")
return False return False
current_time = datetime.datetime.now() current_time = datetime.datetime.utcnow().replace(tzinfo=pytz.utc)
if expiry >= current_time: if expiry >= current_time:
log.debug(f"{self.address}: Invite valid") log.debug(f"{self.address}: Invite valid")
pretty = self.pretty_time(expiry) pretty = self.pretty_time(expiry, tzaware=True)
email_message = config["email"]["message"] email_message = config["email"]["message"]
for key in ["text", "html"]: for key in ["text", "html"]:
sp = Path(config["password_resets"]["email_" + key]) / ".." fpath = Path(config["password_resets"]["email_" + key])
sp = str(sp.resolve()) + "/" with open(fpath, 'r') as f:
template_loader = FileSystemLoader(searchpath=sp) template = Template(f.read())
template_env = Environment(loader=template_loader)
fname = Path(config["password_resets"]["email_" + key]).name
template = template_env.get_template(fname)
c = template.render( c = template.render(
username=reset["UserName"], username=reset["UserName"],
expiry_date=pretty["date"], expiry_date=pretty["date"],
@@ -120,6 +163,11 @@ class Email:
class Mailgun(Email): class Mailgun(Email):
errors = {
400: "Mailgun failed with 400: Bad request",
401: "Mailgun failed with 401: Invalid API key",
}
def __init__(self, address): def __init__(self, address):
super().__init__(address) super().__init__(address)
self.api_url = config["mailgun"]["api_url"] self.api_url = config["mailgun"]["api_url"]
@@ -141,7 +189,12 @@ class Mailgun(Email):
if response.ok: if response.ok:
log.info(f"{self.address}: Sent via mailgun.") log.info(f"{self.address}: Sent via mailgun.")
return True return True
log.debug(f"{self.address}: Mailgun: {response.status_code}") elif response.status_code in Mailgun.errors:
log.error(f"{self.address}: {Mailgun.errors[response.status_code]}")
else:
log.error(
f"{self.address}: Mailgun failed with error {response.status_code}"
)
return response return response
@@ -189,9 +242,9 @@ class Smtp(Email):
log.info(f"{self.address}: Sent via smtp (starttls)") log.info(f"{self.address}: Sent via smtp (starttls)")
return True return True
except Exception as e: except Exception as e:
err = f"{self.address}: Failed to send via smtp: " log.error(
err += type(e).__name__ f"{self.address}: Failed to send via smtp ({type(e).__name__}: {e.args})"
log.error(err) )
try: try:
log.error(e.smtp_error) log.error(e.smtp_error)
except: except:

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

@@ -0,0 +1,43 @@
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.
if len(invites) != 0:
checkInvite(list(invites.keys())[0])
if config.getboolean("notifications", "enabled"):
inviteDaemon = Repeat(60, checkInvites)

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env python3 # Jellyfin API client
import requests import requests
import time import time
@@ -44,7 +44,7 @@ class Jellyfin:
pass pass
def __init__(self, server, client, version, device, deviceId): def __init__(self, server, client, version, device, deviceId, cacheMinutes=30):
""" """
Initializes the Jellyfin object. All parameters except server Initializes the Jellyfin object. All parameters except server
have no effect on the client's capability. have no effect on the client's capability.
@@ -61,7 +61,8 @@ class Jellyfin:
self.version = version self.version = version
self.device = device self.device = device
self.deviceId = deviceId self.deviceId = deviceId
self.timeout = 30 * 60 self.authenticated = False
self.timeout = cacheMinutes * 60
self.userCacheAge = time.time() - self.timeout - 1 self.userCacheAge = time.time() - self.timeout - 1
self.userCachePublicAge = self.userCacheAge self.userCachePublicAge = self.userCacheAge
self.useragent = f"{self.client}/{self.version}" self.useragent = f"{self.client}/{self.version}"
@@ -80,10 +81,20 @@ class Jellyfin:
"X-Emby-Authorization": self.auth, "X-Emby-Authorization": self.auth,
} }
try: try:
self.info = requests.get(self.server + "/System/Info/Public").json() self.info = requests.get(f"{self.server}/System/Info/Public").json()
except: except:
pass pass
def reloadCache(self):
""" Forces a reload of the user caches """
self.userCachePublicAge = time.time() - self.timeout - 1
self.getUsers()
try:
self.userCacheAge = self.userCachePublicAge
self.getUsers(public=False)
except self.AuthenticationRequiredError:
pass
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. Returns details on user(s), such as ID, Name, Policy.
@@ -97,7 +108,7 @@ class Jellyfin:
""" """
if public is True: if public is True:
if (time.time() - self.userCachePublicAge) >= self.timeout: if (time.time() - self.userCachePublicAge) >= self.timeout:
response = requests.get(self.server + "/emby/Users/Public").json() response = requests.get(f"{self.server}/Users/Public").json()
self.userCachePublic = response self.userCachePublic = response
self.userCachePublicAge = time.time() self.userCachePublicAge = time.time()
else: else:
@@ -107,7 +118,7 @@ class Jellyfin:
): ):
if (time.time() - self.userCacheAge) >= self.timeout: if (time.time() - self.userCacheAge) >= self.timeout:
response = requests.get( response = requests.get(
self.server + "/emby/Users", f"{self.server}/Users",
headers=self.header, headers=self.header,
params={"Username": self.username, "Pw": self.password}, params={"Username": self.username, "Pw": self.password},
) )
@@ -115,7 +126,7 @@ class Jellyfin:
response = response.json() response = response.json()
self.userCache = response self.userCache = response
self.userCacheAge = time.time() self.userCacheAge = time.time()
else: elif response.status_code == 401:
try: try:
self.authenticate(self.username, self.password) self.authenticate(self.username, self.password)
return self.getUsers(username, userId, public) return self.getUsers(username, userId, public)
@@ -151,12 +162,10 @@ class Jellyfin:
:param username: Plaintext username. :param username: Plaintext username.
:param password: Plaintext password. :param password: Plaintext password.
""" """
self.username = username
self.password = password
response = requests.post( response = requests.post(
self.server + "/emby/Users/AuthenticateByName", f"{self.server}/Users/AuthenticateByName",
headers=self.header, headers=self.header,
params={"Username": self.username, "Pw": self.password}, params={"Username": username, "Pw": password},
) )
if response.status_code == 200: if response.status_code == 200:
json = response.json() json = response.json()
@@ -170,8 +179,11 @@ class Jellyfin:
self.auth += f", Token={self.accessToken}" self.auth += f", Token={self.accessToken}"
self.header["X-Emby-Authorization"] = self.auth self.header["X-Emby-Authorization"] = self.auth
self.info = requests.get( self.info = requests.get(
self.server + "/System/Info", headers=self.header f"{self.server}/System/Info", headers=self.header
).json() ).json()
self.username = username
self.password = password
self.authenticated = True
return True return True
else: else:
raise self.AuthenticationError raise self.AuthenticationError
@@ -184,17 +196,17 @@ class Jellyfin:
:param policy: User policy in dictionary form. :param policy: User policy in dictionary form.
""" """
return requests.post( return requests.post(
self.server + "/Users/" + userId + "/Policy", f"{self.server}/Users/" + userId + "/Policy",
headers=self.header, headers=self.header,
params=policy, params=policy,
) )
def newUser(self, username: str, password: str): def newUser(self, username: str, password: str):
for user in self.getUsers(): for user in self.getUsers(public=False):
if user["Name"] == username: if user["Name"] == username:
raise self.UserExistsError raise self.UserExistsError
response = requests.post( response = requests.post(
self.server + "/emby/Users/New", f"{self.server}/Users/New",
headers=self.header, headers=self.header,
params={"Name": username, "Password": password}, params={"Name": username, "Password": password},
) )
@@ -212,7 +224,7 @@ class Jellyfin:
else: else:
param = "" param = ""
views = requests.get( views = requests.get(
self.server + "/Users/" + userId + "/Views" + param, headers=self.header f"{self.server}/Users/" + userId + "/Views" + param, headers=self.header
).json()["Items"] ).json()["Items"]
orderedViews = [] orderedViews = []
for library in views: for library in views:
@@ -226,7 +238,7 @@ class Jellyfin:
:param configuration: Configuration to write in dictionary form. :param configuration: Configuration to write in dictionary form.
""" """
resp = requests.post( resp = requests.post(
self.server + "/Users/" + userId + "/Configuration", f"{self.server}/Users/" + userId + "/Configuration",
headers=self.header, headers=self.header,
params=configuration, params=configuration,
) )

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 (
@@ -106,6 +106,8 @@ def verify_password(username, password):
user = Account().verify_token(username, accounts) user = Account().verify_token(username, accounts)
if user: if user:
verified = True verified = True
if user in accounts:
user = accounts[user]
if not user: if not user:
log.debug(f"User {username} not found on Jellyfin") log.debug(f"User {username} not found on Jellyfin")
return False return False
@@ -116,10 +118,10 @@ def verify_password(username, password):
if username == user.username and user.verify_password(password): if username == user.username and user.verify_password(password):
g.user = user g.user = user
log.debug("HTTPAuth Allowed") log.debug("HTTPAuth Allowed")
return True return user
else: else:
log.debug("HTTPAuth Denied") log.debug("HTTPAuth Denied")
return False return False
g.user = user g.user = user
log.debug("HTTPAuth Allowed") log.debug("HTTPAuth Allowed")
return True return user

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
@@ -5,7 +6,7 @@ from watchdog.events import FileSystemEventHandler
from jellyfin_accounts.email import Mailgun, Smtp from jellyfin_accounts.email import Mailgun, Smtp
from jellyfin_accounts.web_api import jf from jellyfin_accounts.web_api import jf
from jellyfin_accounts import config, data_store from jellyfin_accounts import config, data_store
from jellyfin_accounts import email_log as log from jellyfin_accounts import pwr_log as log
class Watcher: class Watcher:
@@ -18,7 +19,8 @@ class Watcher:
self.observer.schedule(event_handler, self.dir, recursive=True) self.observer.schedule(event_handler, self.dir, recursive=True)
try: try:
self.observer.start() self.observer.start()
except NotADirectoryError: except (NotADirectoryError,
FileNotFoundError):
log.error(f"Directory {self.dir} does not exist") log.error(f"Directory {self.dir} does not exist")
try: try:
while True: while True:

View File

@@ -1,9 +1,12 @@
# 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
from jellyfin_accounts import config, config_path, app, first_run, resp from jellyfin_accounts import config, config_path, app, first_run, resp
from jellyfin_accounts import web_log as log from jellyfin_accounts import web_log as log
import os import os
import psutil
import sys
if first_run: if first_run:
@@ -50,8 +53,16 @@ if first_run:
with open(config_path, "w") as config_file: with open(config_path, "w") as config_file:
temp_config.write(config_file) temp_config.write(config_file)
log.debug("Config written") log.debug("Config written")
# ugly exit, sorry log.info('Restarting...')
os._exit(1) try:
p = psutil.Process(os.getpid())
for handler in p.open_files() + p.connections():
os.close(handler.fd)
except:
pass
python = sys.executable
os.execl(python, python, *sys.argv)
return resp() return resp()
@app.route("/testJF", methods=["GET", "POST"]) @app.route("/testJF", methods=["GET", "POST"])

View File

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

View File

@@ -1,9 +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
def bsVersion():
if config.getboolean("ui", "bs5"):
return 5
return 4
@app.errorhandler(404) @app.errorhandler(404)
@@ -21,7 +28,6 @@ def page_not_found(e):
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def admin(): def admin():
# return app.send_static_file('admin.html')
return render_template( return render_template(
"admin.html", "admin.html",
bs5=config.getboolean("ui", "bs5"), bs5=config.getboolean("ui", "bs5"),
@@ -35,11 +41,16 @@ def admin():
def static_proxy(path): def static_proxy(path):
if "html" not in path: if "html" not in path:
if "admin.js" in path: if "admin.js" in path:
if config.getboolean("ui", "bs5"): return (
bsVersion = 5 render_template(
else: "admin.js",
bsVersion = 4 bsVersion=bsVersion(),
return render_template("admin.js", bsVersion=bsVersion) css_file=css_file,
notifications=config.getboolean("notifications", "enabled"),
),
200,
{"Content-Type": "text/javascript"},
)
return app.send_static_file(path) return app.send_static_file(path)
return ( return (
render_template( render_template(
@@ -69,7 +80,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,14 +1,17 @@
# 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
import datetime import datetime
import secrets import secrets
import time import time
import threading
import os
import sys
import psutil
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,
@@ -16,25 +19,70 @@ from jellyfin_accounts import (
configparser, configparser,
config_base_path, config_base_path,
) )
from jellyfin_accounts.email import Mailgun, Smtp
from jellyfin_accounts import web_log as log from jellyfin_accounts import web_log as log
from jellyfin_accounts.validate_password import PasswordValidator from jellyfin_accounts.validate_password import PasswordValidator
def checkInvite(code, delete=False): 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
def checkInvite(code, used=False, username=None):
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
invites = dict(data_store.invites) invites = dict(data_store.invites)
match = False match = False
for invite in invites: for invite in invites:
if (
"remaining-uses" not in invites[invite]
and "no-limit" not in invites[invite]
):
invites[invite]["remaining-uses"] = 1
expiry = datetime.datetime.strptime( expiry = datetime.datetime.strptime(
invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f" invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
) )
if current_time >= expiry: if current_time >= expiry or (
log.debug(f"Housekeeping: Deleting old invite {invite}") "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}
):
threading.Thread(target=email.send).start()
del data_store.invites[invite] del data_store.invites[invite]
elif invite == code: elif invite == code:
match = True match = True
if delete: if used:
del data_store.invites[code] delete = False
inv = dict(data_store.invites[code])
if "used-by" not in inv:
inv["used-by"] = []
if "remaining-uses" in inv:
if inv["remaining-uses"] == 1:
delete = True
del data_store.invites[code]
elif "no-limit" not in invites[invite]:
inv["remaining-uses"] -= 1
inv["used-by"].append([username, format_datetime(current_time)])
if not delete:
data_store.invites[code] = inv
return match return match
@@ -108,11 +156,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"
@@ -125,16 +173,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"])
@@ -142,7 +190,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:
@@ -157,7 +205,28 @@ def newUser():
return jsonify({"error": error}) return jsonify({"error": error})
except: except:
return jsonify({"error": "Unknown error"}) return jsonify({"error": "Unknown error"})
checkInvite(data["code"], delete=True) 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(),
}
):
threading.Thread(target=email.send).start()
if user.status_code == 200: if user.status_code == 200:
try: try:
policy = data_store.user_template policy = data_store.user_template
@@ -175,9 +244,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"):
@@ -200,9 +267,19 @@ def newUser():
def generateInvite(): def generateInvite():
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
data = request.get_json() data = request.get_json()
delta = datetime.timedelta(hours=int(data["hours"]), minutes=int(data["minutes"])) delta = datetime.timedelta(
days=int(data["days"]), hours=int(data["hours"]), minutes=int(data["minutes"])
)
invite_code = secrets.token_urlsafe(16) invite_code = secrets.token_urlsafe(16)
invite = {} invite = {}
invite["created"] = format_datetime(current_time)
if data["multiple-uses"]:
if data["no-limit"]:
invite["no-limit"] = True
else:
invite["remaining-uses"] = int(data["remaining-uses"])
else:
invite["remaining-uses"] = 1
log.debug(f"Creating new invite: {invite_code}") log.debug(f"Creating new invite: {invite_code}")
valid_till = current_time + delta valid_till = current_time + delta
invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f") invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f")
@@ -223,6 +300,11 @@ def generateInvite():
response = email.send() response = email.send()
if response is False or type(response) != bool: if response is False or type(response) != bool:
invite["email"] = f"Failed to send to {address}" invite["email"] = f"Failed to send to {address}"
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 data_store.invites[invite_code] = invite
log.info(f"New invite created: {invite_code}") log.info(f"New invite created: {invite_code}")
return resp() return resp()
@@ -245,11 +327,36 @@ def getInvites():
valid_for = expiry - current_time valid_for = expiry - current_time
invite = { invite = {
"code": code, "code": code,
"days": valid_for.days,
"hours": valid_for.seconds // 3600, "hours": valid_for.seconds // 3600,
"minutes": (valid_for.seconds // 60) % 60, "minutes": (valid_for.seconds // 60) % 60,
} }
if "created" in invites[code]:
invite["created"] = invites[code]["created"]
if "used-by" in invites[code]:
invite["used-by"] = invites[code]["used-by"]
if "no-limit" in invites[code]:
invite["no-limit"] = invites[code]["no-limit"]
if "remaining-uses" in invites[code]:
invite["remaining-uses"] = invites[code]["remaining-uses"]
else:
invite["remaining-uses"] = 1
if "email" in invites[code]: if "email" in invites[code]:
invite["email"] = invites[code]["email"] 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) response["invites"].append(invite)
return jsonify(response) return jsonify(response)
@@ -334,27 +441,31 @@ def modifyConfig():
temp_config = configparser.RawConfigParser( temp_config = configparser.RawConfigParser(
comment_prefixes="/", allow_no_value=True comment_prefixes="/", allow_no_value=True
) )
temp_config.read(config_path) temp_config.read(str(config_path.resolve()))
for section in data: for section in data:
if section in temp_config: if section in temp_config and 'restart-program' not in section:
for item in data[section]: for item in data[section]:
temp_config[section][item] = data[section][item] temp_config[section][item] = data[section][item]
data[section][item] = True data[section][item] = True
log.debug(f"{section}/{item} modified") log.debug(f"{section}/{item} modified")
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.")
if 'restart-program' in data:
if data['restart-program']:
log.info('Restarting...')
try:
proc = psutil.Process(os.getpid())
for handler in proc.open_files() + proc.connections():
os.close(handler.fd)
except Exception as e:
log.error(f'Failed restart: {type(e).__name__}')
python = sys.executable
os.execl(python, python, *sys.argv)
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():
@@ -368,3 +479,29 @@ def getConfig():
if entry in config[section]: if entry in config[section]:
response_config[section][entry]["value"] = config[section][entry] response_config[section][entry]["value"] = config[section][entry]
return jsonify(response_config), 200 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)

47
mail/created.mjml Normal file
View File

@@ -0,0 +1,47 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>User Created</h3>
<p>A user was created using code {{ code }}.</p>
</mj-text>
<mj-table mj-class="text" container-background-color="#242424">
<tr style="text-align: left;">
<th>Name</th>
<th>Address</th>
<th>Time</th>
</tr>
<tr style="font-style: italic; text-align: left; color: rgb(153,153,153);">
<th>{{ username }}</th>
<th>{{ address }}</th>
<th>{{ time }}</th>
</mj-table>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
Notification emails can be toggled on the admin dashboard.
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

7
mail/created.txt Normal file
View File

@@ -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.

40
mail/email.mjml Normal file
View File

@@ -0,0 +1,40 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p>
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }} UTC, which is in {{ expires_in }}.</p>
<p>If this wasn't you, please ignore this email.</p>
</mj-text>
<mj-button mj-class="blue bold">{{ pin }}</mj-button>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

View File

@@ -2,7 +2,7 @@ Hi {{ username }},
Someone has recently requests a password reset on Jellyfin. Someone has recently requests a password reset on Jellyfin.
If this was you, enter the below pin into the prompt. 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 }}. This code will expire on {{ expiry_date }}, at {{ expiry_time }} UTC, which is in {{ expires_in }}.
If this wasn't you, please ignore this email. If this wasn't you, please ignore this email.
PIN: {{ pin }} PIN: {{ pin }}

36
mail/expired.mjml Normal file
View File

@@ -0,0 +1,36 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>Invite Expired.</h3>
<p>Code {{ code }} expired at {{ expiry }}.</p>
</mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
Notification emails can be toggled on the admin dashboard.
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

5
mail/expired.txt Normal file
View File

@@ -0,0 +1,5 @@
Invite expired.
Code {{ code }} expired at {{ expiry }}.
Note: Notification emails can be toggled on the admin dashboard.

60
mail/generate.py Executable file
View File

@@ -0,0 +1,60 @@
import subprocess
import shutil
import os
import argparse
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument(
"-y", "--yes", help="use assumed node bin directory.", action="store_true"
)
def runcmd(cmd):
if os.name == "nt":
return subprocess.check_output(cmd, shell=True)
proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
return proc.communicate()
local_path = Path(__file__).resolve().parent
out = runcmd("npm bin")
try:
node_bin = Path(out[0].decode('utf-8').rstrip())
except:
node_bin = Path(out.decode('utf-8').rstrip())
args = parser.parse_args()
if not args.yes:
print(f"assuming npm bin directory \"{node_bin}\". Is this correct?")
if input("[yY/nN]: ").lower() == "n":
node_bin = local_path.parent / 'node_modules' / '.bin'
print(f"this? \"{node_bin}\"")
if input("[yY/nN]: ").lower() == "n":
node_bin = input("input bin directory: ")
for mjml in [f for f in local_path.iterdir() if f.is_file() and 'mjml' in f.suffix]:
print(f'Compiling {mjml.name}')
fname = mjml.with_suffix(".html")
runcmd(f'{str(node_bin / "mjml")} {str(mjml)} -o {str(fname)}')
if fname.is_file():
print('Done.')
html = [f for f in local_path.iterdir() if f.is_file() and 'html' in f.suffix]
output = local_path.parent / 'jellyfin_accounts' / 'data'
for f in html:
shutil.copy(str(f),
str(output / f.name))
print(f'Copied {f.name} to {str(output / f.name)}')
txtfile = f.with_suffix('.txt')
if txtfile.is_file():
shutil.copy(str(txtfile),
str(output / txtfile.name))
print(f'Copied {txtfile.name} to {str(output / txtfile.name)}')
else:
print(f'Warning: {txtfile.name} does not exist. Text versions of emails should be supplied.')

39
mail/invite-email.mjml Normal file
View File

@@ -0,0 +1,39 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi,</p>
<h3>You've been invited to Jellyfin.</h3>
<p>To join, click the button below.</p>
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
</mj-text>
<mj-button mj-class="blue bold" href="{{ invite_link }}">Setup your account</mj-button>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

1474
package-lock.json generated

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "jellyfin-accounts",
"version": "1.0.0",
"description": "This is only used for grabbing scss build dependencies, and isn't a real package.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/hrfee/jellyfin-accounts.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/hrfee/jellyfin-accounts/issues"
},
"homepage": "https://github.com/hrfee/jellyfin-accounts#readme",
"dependencies": {
"autoprefixer": "^9.8.5",
"bootstrap": "^5.0.0-alpha1",
"bootstrap4": "npm:bootstrap@^4.5.0",
"clean-css-cli": "^4.3.0",
"lodash": "^4.17.19",
"mjml": "^4.6.3",
"postcss-cli": "^7.1.1"
}
}

249
poetry.lock generated
View File

@@ -46,7 +46,7 @@ description = "Python package for providing Mozilla's CA Bundle."
name = "certifi" name = "certifi"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "2020.4.5.2" version = "2020.6.20"
[[package]] [[package]]
category = "main" category = "main"
@@ -54,7 +54,7 @@ description = "Foreign Function Interface for Python calling C code."
name = "cffi" name = "cffi"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "1.14.0" version = "1.14.1"
[package.dependencies] [package.dependencies]
pycparser = "*" pycparser = "*"
@@ -81,17 +81,18 @@ description = "cryptography is a package which provides cryptographic recipes an
name = "cryptography" name = "cryptography"
optional = false optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
version = "2.9.2" version = "3.0"
[package.dependencies] [package.dependencies]
cffi = ">=1.8,<1.11.3 || >1.11.3" cffi = ">=1.8,<1.11.3 || >1.11.3"
six = ">=1.4.1" six = ">=1.4.1"
[package.extras] [package.extras]
docs = ["sphinx (>=1.6.5,<1.8.0 || >1.8.0)", "sphinx-rtd-theme"] docs = ["sphinx (>=1.6.5,<1.8.0 || >1.8.0,<3.1.0 || >3.1.0,<3.1.1 || >3.1.1)", "sphinx-rtd-theme"]
docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
idna = ["idna (>=2.1)"] idna = ["idna (>=2.1)"]
pep8test = ["flake8", "flake8-import-order", "pep8-naming"] pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["pytest (>=3.6.0,<3.9.0 || >3.9.0,<3.9.1 || >3.9.1,<3.9.2 || >3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,<3.79.2 || >3.79.2)"] test = ["pytest (>=3.6.0,<3.9.0 || >3.9.0,<3.9.1 || >3.9.1,<3.9.2 || >3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,<3.79.2 || >3.79.2)"]
[[package]] [[package]]
@@ -119,7 +120,7 @@ description = "Basic and Digest HTTP authentication for Flask routes"
name = "flask-httpauth" name = "flask-httpauth"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "3.3.0" version = "4.1.0"
[package.dependencies] [package.dependencies]
Flask = "*" Flask = "*"
@@ -138,7 +139,7 @@ description = "Internationalized Domain Names in Applications (IDNA)"
name = "idna" name = "idna"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.9" version = "2.10"
[[package]] [[package]]
category = "main" category = "main"
@@ -162,6 +163,17 @@ MarkupSafe = ">=0.23"
[package.extras] [package.extras]
i18n = ["Babel (>=0.8)"] i18n = ["Babel (>=0.8)"]
[[package]]
category = "dev"
description = "Sass for Python: A straightforward binding of libsass for Python."
name = "libsass"
optional = false
python-versions = "*"
version = "0.20.0"
[package.dependencies]
six = "*"
[[package]] [[package]]
category = "main" category = "main"
description = "Safely add untrusted strings to HTML/XML markup." description = "Safely add untrusted strings to HTML/XML markup."
@@ -231,6 +243,17 @@ optional = false
python-versions = "*" python-versions = "*"
version = "0.1.2" version = "0.1.2"
[[package]]
category = "main"
description = "Cross-platform lib for process and system monitoring in Python."
name = "psutil"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "5.7.2"
[package.extras]
test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
[[package]] [[package]]
category = "main" category = "main"
description = "C parser in Python" description = "C parser in Python"
@@ -304,7 +327,7 @@ description = "Alternative regular expression module, to replace re."
name = "regex" name = "regex"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "2020.6.8" version = "2020.7.14"
[[package]] [[package]]
category = "main" category = "main"
@@ -312,7 +335,7 @@ description = "Python HTTP for Humans."
name = "requests" name = "requests"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "2.23.0" version = "2.24.0"
[package.dependencies] [package.dependencies]
certifi = ">=2017.4.17" certifi = ">=2017.4.17"
@@ -332,6 +355,17 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.15.0" version = "1.15.0"
[[package]]
category = "dev"
description = "tasks runner for python projects"
name = "taskipy"
optional = false
python-versions = ">=3.6,<4.0"
version = "1.2.1"
[package.dependencies]
toml = ">=0.10.0,<0.11.0"
[[package]] [[package]]
category = "dev" category = "dev"
description = "Python Library for Tom's Obvious, Minimal Language" description = "Python Library for Tom's Obvious, Minimal Language"
@@ -354,7 +388,7 @@ description = "HTTP library with thread-safe connection pooling, file post, and
name = "urllib3" name = "urllib3"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
version = "1.25.9" version = "1.25.10"
[package.extras] [package.extras]
brotli = ["brotlipy (>=0.6.0)"] brotli = ["brotlipy (>=0.6.0)"]
@@ -379,7 +413,7 @@ description = "Filesystem events monitoring"
name = "watchdog" name = "watchdog"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "0.10.2" version = "0.10.3"
[package.dependencies] [package.dependencies]
pathtools = ">=0.1.1" pathtools = ">=0.1.1"
@@ -400,7 +434,8 @@ dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-
watchdog = ["watchdog"] watchdog = ["watchdog"]
[metadata] [metadata]
content-hash = "847ce2a6a3927efdfb3b78935b348e9b4dc63d7e60959af6cc8b9fbc5a24567b" content-hash = "1c2741c9be187d9d0be662509fb4a87f5978e5f44420e5049a20504824c29a59"
lock-version = "1.0"
python-versions = "^3.6" python-versions = "^3.6"
[metadata.files] [metadata.files]
@@ -417,38 +452,38 @@ black = [
{file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
] ]
certifi = [ certifi = [
{file = "certifi-2020.4.5.2-py2.py3-none-any.whl", hash = "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc"}, {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"},
{file = "certifi-2020.4.5.2.tar.gz", hash = "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1"}, {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"},
] ]
cffi = [ cffi = [
{file = "cffi-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384"}, {file = "cffi-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2"},
{file = "cffi-1.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30"}, {file = "cffi-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8"},
{file = "cffi-1.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"}, {file = "cffi-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1"},
{file = "cffi-1.14.0-cp27-cp27m-win32.whl", hash = "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78"}, {file = "cffi-1.14.1-cp27-cp27m-win32.whl", hash = "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9"},
{file = "cffi-1.14.0-cp27-cp27m-win_amd64.whl", hash = "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793"}, {file = "cffi-1.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1"},
{file = "cffi-1.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e"}, {file = "cffi-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168"},
{file = "cffi-1.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a"}, {file = "cffi-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf"},
{file = "cffi-1.14.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff"}, {file = "cffi-1.14.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e"},
{file = "cffi-1.14.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f"}, {file = "cffi-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849"},
{file = "cffi-1.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa"}, {file = "cffi-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c"},
{file = "cffi-1.14.0-cp35-cp35m-win32.whl", hash = "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5"}, {file = "cffi-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa"},
{file = "cffi-1.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4"}, {file = "cffi-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948"},
{file = "cffi-1.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d"}, {file = "cffi-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f"},
{file = "cffi-1.14.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc"}, {file = "cffi-1.14.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3"},
{file = "cffi-1.14.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac"}, {file = "cffi-1.14.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc"},
{file = "cffi-1.14.0-cp36-cp36m-win32.whl", hash = "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f"}, {file = "cffi-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2"},
{file = "cffi-1.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b"}, {file = "cffi-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022"},
{file = "cffi-1.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3"}, {file = "cffi-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9"},
{file = "cffi-1.14.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66"}, {file = "cffi-1.14.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0"},
{file = "cffi-1.14.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0"}, {file = "cffi-1.14.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33"},
{file = "cffi-1.14.0-cp37-cp37m-win32.whl", hash = "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f"}, {file = "cffi-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792"},
{file = "cffi-1.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26"}, {file = "cffi-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96"},
{file = "cffi-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd"}, {file = "cffi-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc"},
{file = "cffi-1.14.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55"}, {file = "cffi-1.14.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939"},
{file = "cffi-1.14.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2"}, {file = "cffi-1.14.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe"},
{file = "cffi-1.14.0-cp38-cp38-win32.whl", hash = "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8"}, {file = "cffi-1.14.1-cp38-cp38-win32.whl", hash = "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995"},
{file = "cffi-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b"}, {file = "cffi-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90"},
{file = "cffi-1.14.0.tar.gz", hash = "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6"}, {file = "cffi-1.14.1.tar.gz", hash = "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f"},
] ]
chardet = [ chardet = [
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
@@ -459,33 +494,33 @@ click = [
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
] ]
cryptography = [ cryptography = [
{file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"}, {file = "cryptography-3.0-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:ab49edd5bea8d8b39a44b3db618e4783ef84c19c8b47286bf05dfdb3efb01c83"},
{file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"}, {file = "cryptography-3.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:124af7255ffc8e964d9ff26971b3a6153e1a8a220b9a685dc407976ecb27a06a"},
{file = "cryptography-2.9.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365"}, {file = "cryptography-3.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:51e40123083d2f946794f9fe4adeeee2922b581fa3602128ce85ff813d85b81f"},
{file = "cryptography-2.9.2-cp27-cp27m-win32.whl", hash = "sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0"}, {file = "cryptography-3.0-cp27-cp27m-win32.whl", hash = "sha256:dea0ba7fe6f9461d244679efa968d215ea1f989b9c1957d7f10c21e5c7c09ad6"},
{file = "cryptography-2.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55"}, {file = "cryptography-3.0-cp27-cp27m-win_amd64.whl", hash = "sha256:8ecf9400d0893836ff41b6f977a33972145a855b6efeb605b49ee273c5e6469f"},
{file = "cryptography-2.9.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270"}, {file = "cryptography-3.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c608ff4d4adad9e39b5057de43657515c7da1ccb1807c3a27d4cf31fc923b4b"},
{file = "cryptography-2.9.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf"}, {file = "cryptography-3.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:bec7568c6970b865f2bcebbe84d547c52bb2abadf74cefce396ba07571109c67"},
{file = "cryptography-2.9.2-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d"}, {file = "cryptography-3.0-cp35-abi3-macosx_10_10_x86_64.whl", hash = "sha256:0cbfed8ea74631fe4de00630f4bb592dad564d57f73150d6f6796a24e76c76cd"},
{file = "cryptography-2.9.2-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785"}, {file = "cryptography-3.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:a09fd9c1cca9a46b6ad4bea0a1f86ab1de3c0c932364dbcf9a6c2a5eeb44fa77"},
{file = "cryptography-2.9.2-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b"}, {file = "cryptography-3.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:ce82cc06588e5cbc2a7df3c8a9c778f2cb722f56835a23a68b5a7264726bb00c"},
{file = "cryptography-2.9.2-cp35-cp35m-win32.whl", hash = "sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae"}, {file = "cryptography-3.0-cp35-cp35m-win32.whl", hash = "sha256:9367d00e14dee8d02134c6c9524bb4bd39d4c162456343d07191e2a0b5ec8b3b"},
{file = "cryptography-2.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b"}, {file = "cryptography-3.0-cp35-cp35m-win_amd64.whl", hash = "sha256:384d7c681b1ab904fff3400a6909261cae1d0939cc483a68bdedab282fb89a07"},
{file = "cryptography-2.9.2-cp36-cp36m-win32.whl", hash = "sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6"}, {file = "cryptography-3.0-cp36-cp36m-win32.whl", hash = "sha256:4d355f2aee4a29063c10164b032d9fa8a82e2c30768737a2fd56d256146ad559"},
{file = "cryptography-2.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3"}, {file = "cryptography-3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:45741f5499150593178fc98d2c1a9c6722df88b99c821ad6ae298eff0ba1ae71"},
{file = "cryptography-2.9.2-cp37-cp37m-win32.whl", hash = "sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b"}, {file = "cryptography-3.0-cp37-cp37m-win32.whl", hash = "sha256:8ecef21ac982aa78309bb6f092d1677812927e8b5ef204a10c326fc29f1367e2"},
{file = "cryptography-2.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e"}, {file = "cryptography-3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4b9303507254ccb1181d1803a2080a798910ba89b1a3c9f53639885c90f7a756"},
{file = "cryptography-2.9.2-cp38-cp38-win32.whl", hash = "sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0"}, {file = "cryptography-3.0-cp38-cp38-win32.whl", hash = "sha256:8713ddb888119b0d2a1462357d5946b8911be01ddbf31451e1d07eaa5077a261"},
{file = "cryptography-2.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5"}, {file = "cryptography-3.0-cp38-cp38-win_amd64.whl", hash = "sha256:bea0b0468f89cdea625bb3f692cd7a4222d80a6bdafd6fb923963f2b9da0e15f"},
{file = "cryptography-2.9.2.tar.gz", hash = "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229"}, {file = "cryptography-3.0.tar.gz", hash = "sha256:8e924dbc025206e97756e8903039662aa58aa9ba357d8e1d8fc29e3092322053"},
] ]
flask = [ flask = [
{file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"},
{file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"},
] ]
flask-httpauth = [ flask-httpauth = [
{file = "Flask-HTTPAuth-3.3.0.tar.gz", hash = "sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9"}, {file = "Flask-HTTPAuth-4.1.0.tar.gz", hash = "sha256:9e028e4375039a49031eb9ecc40be4761f0540476040f6eff329a31dabd4d000"},
{file = "Flask_HTTPAuth-3.3.0-py2.py3-none-any.whl", hash = "sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72"}, {file = "Flask_HTTPAuth-4.1.0-py2.py3-none-any.whl", hash = "sha256:29e0288869a213c7387f0323b6bf2c7191584fb1da8aa024d9af118e5cd70de7"},
] ]
greenlet = [ greenlet = [
{file = "greenlet-0.4.16-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872"}, {file = "greenlet-0.4.16-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872"},
@@ -507,8 +542,8 @@ greenlet = [
{file = "greenlet-0.4.16.tar.gz", hash = "sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c"}, {file = "greenlet-0.4.16.tar.gz", hash = "sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c"},
] ]
idna = [ idna = [
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
{file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
] ]
itsdangerous = [ itsdangerous = [
{file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"},
@@ -518,6 +553,21 @@ jinja2 = [
{file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"},
{file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"},
] ]
libsass = [
{file = "libsass-0.20.0-cp27-cp27m-macosx_10_14_intel.whl", hash = "sha256:107c409524c6a4ed14410fa9dafa9ee59c6bd3ecae75d73af749ab2b75685726"},
{file = "libsass-0.20.0-cp27-cp27m-win32.whl", hash = "sha256:98f6dee9850b29e62977a963e3beb3cfeb98b128a267d59d2c3d675e298c8d57"},
{file = "libsass-0.20.0-cp27-cp27m-win_amd64.whl", hash = "sha256:b077261a04ba1c213e932943208471972c5230222acb7fa97373e55a40872cbb"},
{file = "libsass-0.20.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e6a547c0aa731dcb4ed71f198e814bee0400ce04d553f3f12a53bc3a17f2a481"},
{file = "libsass-0.20.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:74f6fb8da58179b5d86586bc045c16d93d55074bc7bb48b6354a4da7ac9f9dfd"},
{file = "libsass-0.20.0-cp36-cp36m-win32.whl", hash = "sha256:a43f3830d83ad9a7f5013c05ce239ca71744d0780dad906587302ac5257bce60"},
{file = "libsass-0.20.0-cp36-cp36m-win_amd64.whl", hash = "sha256:fd19c8f73f70ffc6cbcca8139da08ea9a71fc48e7dfc4bb236ad88ab2d6558f1"},
{file = "libsass-0.20.0-cp37-abi3-macosx_10_14_x86_64.whl", hash = "sha256:8cf72552b39e78a1852132e16b706406bc76029fe3001583284ece8d8752a60a"},
{file = "libsass-0.20.0-cp37-cp37m-win32.whl", hash = "sha256:7555d9b24e79943cfafac44dbb4ca7e62105c038de7c6b999838c9ff7b88645d"},
{file = "libsass-0.20.0-cp37-cp37m-win_amd64.whl", hash = "sha256:794f4f4661667263e7feafe5cc866e3746c7c8a9192b2aa9afffdadcbc91c687"},
{file = "libsass-0.20.0-cp38-cp38-win32.whl", hash = "sha256:3bc0d68778b30b5fa83199e18795314f64b26ca5871e026343e63934f616f7f7"},
{file = "libsass-0.20.0-cp38-cp38-win_amd64.whl", hash = "sha256:5c8ff562b233734fbc72b23bb862cc6a6f70b1e9bf85a58422aa75108b94783b"},
{file = "libsass-0.20.0.tar.gz", hash = "sha256:b7452f1df274b166dc22ee2e9154c4adca619bcbbdf8041a7aa05f372a1dacbc"},
]
markupsafe = [ markupsafe = [
{file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"},
@@ -591,6 +641,19 @@ pathspec = [
pathtools = [ pathtools = [
{file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"}, {file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"},
] ]
psutil = [
{file = "psutil-5.7.2-cp27-none-win32.whl", hash = "sha256:f2018461733b23f308c298653c8903d32aaad7873d25e1d228765e91ae42c3f2"},
{file = "psutil-5.7.2-cp27-none-win_amd64.whl", hash = "sha256:66c18ca7680a31bf16ee22b1d21b6397869dda8059dbdb57d9f27efa6615f195"},
{file = "psutil-5.7.2-cp35-cp35m-win32.whl", hash = "sha256:5e9d0f26d4194479a13d5f4b3798260c20cecf9ac9a461e718eb59ea520a360c"},
{file = "psutil-5.7.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4080869ed93cce662905b029a1770fe89c98787e543fa7347f075ade761b19d6"},
{file = "psutil-5.7.2-cp36-cp36m-win32.whl", hash = "sha256:d8a82162f23c53b8525cf5f14a355f5d1eea86fa8edde27287dd3a98399e4fdf"},
{file = "psutil-5.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:0ee3c36428f160d2d8fce3c583a0353e848abb7de9732c50cf3356dd49ad63f8"},
{file = "psutil-5.7.2-cp37-cp37m-win32.whl", hash = "sha256:ff1977ba1a5f71f89166d5145c3da1cea89a0fdb044075a12c720ee9123ec818"},
{file = "psutil-5.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a5b120bb3c0c71dfe27551f9da2f3209a8257a178ed6c628a819037a8df487f1"},
{file = "psutil-5.7.2-cp38-cp38-win32.whl", hash = "sha256:10512b46c95b02842c225f58fa00385c08fa00c68bac7da2d9a58ebe2c517498"},
{file = "psutil-5.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:68d36986ded5dac7c2dcd42f2682af1db80d4bce3faa126a6145c1637e1b559f"},
{file = "psutil-5.7.2.tar.gz", hash = "sha256:90990af1c3c67195c44c9a889184f84f5b2320dce3ee3acbd054e3ba0b4a7beb"},
]
pycparser = [ pycparser = [
{file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
@@ -615,36 +678,40 @@ pytz = [
{file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"},
] ]
regex = [ regex = [
{file = "regex-2020.6.8-cp27-cp27m-win32.whl", hash = "sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c"}, {file = "regex-2020.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"},
{file = "regex-2020.6.8-cp27-cp27m-win_amd64.whl", hash = "sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938"}, {file = "regex-2020.7.14-cp27-cp27m-win_amd64.whl", hash = "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644"},
{file = "regex-2020.6.8-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89"}, {file = "regex-2020.7.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc"},
{file = "regex-2020.6.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868"}, {file = "regex-2020.7.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067"},
{file = "regex-2020.6.8-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f"}, {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd"},
{file = "regex-2020.6.8-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded"}, {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88"},
{file = "regex-2020.6.8-cp36-cp36m-win32.whl", hash = "sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3"}, {file = "regex-2020.7.14-cp36-cp36m-win32.whl", hash = "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4"},
{file = "regex-2020.6.8-cp36-cp36m-win_amd64.whl", hash = "sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd"}, {file = "regex-2020.7.14-cp36-cp36m-win_amd64.whl", hash = "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f"},
{file = "regex-2020.6.8-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a"}, {file = "regex-2020.7.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162"},
{file = "regex-2020.6.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae"}, {file = "regex-2020.7.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf"},
{file = "regex-2020.6.8-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a"}, {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7"},
{file = "regex-2020.6.8-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3"}, {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89"},
{file = "regex-2020.6.8-cp37-cp37m-win32.whl", hash = "sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9"}, {file = "regex-2020.7.14-cp37-cp37m-win32.whl", hash = "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6"},
{file = "regex-2020.6.8-cp37-cp37m-win_amd64.whl", hash = "sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29"}, {file = "regex-2020.7.14-cp37-cp37m-win_amd64.whl", hash = "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204"},
{file = "regex-2020.6.8-cp38-cp38-manylinux1_i686.whl", hash = "sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610"}, {file = "regex-2020.7.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99"},
{file = "regex-2020.6.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387"}, {file = "regex-2020.7.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e"},
{file = "regex-2020.6.8-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910"}, {file = "regex-2020.7.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e"},
{file = "regex-2020.6.8-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf"}, {file = "regex-2020.7.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a"},
{file = "regex-2020.6.8-cp38-cp38-win32.whl", hash = "sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754"}, {file = "regex-2020.7.14-cp38-cp38-win32.whl", hash = "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341"},
{file = "regex-2020.6.8-cp38-cp38-win_amd64.whl", hash = "sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5"}, {file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"},
{file = "regex-2020.6.8.tar.gz", hash = "sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac"}, {file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"},
] ]
requests = [ requests = [
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"},
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"},
] ]
six = [ six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
] ]
taskipy = [
{file = "taskipy-1.2.1-py3-none-any.whl", hash = "sha256:99bdaf5b19791c2345806847147e0fc2d28e1ac9446058def5a8b6b3fc9f23e2"},
{file = "taskipy-1.2.1.tar.gz", hash = "sha256:5eb2c3b1606c896c7fa799848e71e8883b880759224958d07ba760e5db263175"},
]
toml = [ toml = [
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
@@ -673,15 +740,15 @@ typed-ast = [
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
] ]
urllib3 = [ urllib3 = [
{file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"}, {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"},
{file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"},
] ]
waitress = [ waitress = [
{file = "waitress-1.4.4-py2.py3-none-any.whl", hash = "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db"}, {file = "waitress-1.4.4-py2.py3-none-any.whl", hash = "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db"},
{file = "waitress-1.4.4.tar.gz", hash = "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261"}, {file = "waitress-1.4.4.tar.gz", hash = "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261"},
] ]
watchdog = [ watchdog = [
{file = "watchdog-0.10.2.tar.gz", hash = "sha256:c560efb643faed5ef28784b2245cf8874f939569717a4a12826a173ac644456b"}, {file = "watchdog-0.10.3.tar.gz", hash = "sha256:4214e1379d128b0588021880ccaf40317ee156d4603ac388b9adcf29165e0c04"},
] ]
werkzeug = [ werkzeug = [
{file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"}, {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "jellyfin-accounts" name = "jellyfin-accounts"
version = "0.3.0" version = "0.3.9"
readme = "README.md" readme = "README.md"
description = "A simple account management system for Jellyfin" description = "A simple account management system for Jellyfin"
authors = ["Harvey Tindall <harveyltindall@gmail.com>"] authors = ["Harvey Tindall <harveyltindall@gmail.com>"]
@@ -8,15 +8,14 @@ license = "MIT"
homepage = "https://github.com/hrfee/jellyfin-accounts" homepage = "https://github.com/hrfee/jellyfin-accounts"
repository = "https://github.com/hrfee/jellyfin-accounts" repository = "https://github.com/hrfee/jellyfin-accounts"
keywords = ["jellyfin", "jf-accounts"] keywords = ["jellyfin", "jf-accounts"]
include = ["jellyfin_accounts/data/*"] include = ["jellyfin_accounts/data/*", "jellyfin_accounts/data/static/*.css", "jellyfin_accounts/data/*.html"]
exclude = ["images/*", "scss/*"] exclude = ["images/*", "scss/*", "mail/*"]
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.6" python = "^3.6"
pyopenssl = "^19.1.0" pyopenssl = "^19.1.0"
@@ -30,15 +29,24 @@ python-dateutil = "^2.8.1"
watchdog = "^0.10.2" watchdog = "^0.10.2"
waitress = "^1.4.3" waitress = "^1.4.3"
packaging = "^20.4" packaging = "^20.4"
psutil = "^5.7.2"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
neovim = "^0.3.1" neovim = "^0.3.1"
black = "^19.10b0" black = "^19.10b0"
taskipy = "^1.2.1"
libsass = "^0.20.0"
[tool.poetry.scripts] [tool.poetry.scripts]
jf-accounts = 'jellyfin_accounts:main' jf-accounts = 'jellyfin_accounts:main'
[tool.taskipy.tasks]
pre_compile-css = "task get-npm-deps"
compile-css = "python scss/compile.py"
get-npm-deps = "python scss/get_node_deps.py"
pre_generate-emails = "task get-npm-deps"
generate-emails = "python mail/generate.py"
[build-system] [build-system]
requires = ["poetry>=0.12"] requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api" build-backend = "poetry.masonry.api"

View File

@@ -2,15 +2,9 @@
* `bs<4/5>-jf.scss` contains the source for the customizations to bootstrap. To customize the UI, you can make modifications to this file and then compile it. * `bs<4/5>-jf.scss` contains the source for the customizations to bootstrap. To customize the UI, you can make modifications to this file and then compile it.
**Note**: For BS5, it is assumed that bootstrap is installed in `../../node_modules/bootstrap` relative to itself. **Note**: It is assumed that Bootstrap 5 is installed in `../../node_modules/bootstrap` relative to itself, and Bootstrap 4 in `../../node_modules/bootstrap4`.
For BS4, it assumes that bootstrap is installed in `../../node_modules/bootstrap4` relative to itself (`npm install bootstrap4@npm:bootstrap`).
* Compilation requires a sass compiler of your choice, and `postcss-cli`, `autoprefixer` + `clean-css-cli` from npm. * Compilation requires dev dependencies (`poetry update`), bootstrap and some extra npm packages.
* If you're using `sassc`, run `./compile.sh bs<4/5>-jf.scss` in this directory. This will create a .css file, and minified .css file. * If you're buildings from source, you can simply run `poetry run task compile-css` before building to automatically get deps and compile CSS.
* For `node-sass`, replace the `sassc` line in `compile.sh` with * If you are creating custom css, run `poetry run task get-npm-deps` to only install the necessary dependencies. Follow along with the commands `scss/compile.py` runs to build your css and then set `custom_css` in your config as the path to your minified css and change the `theme` option to `Custom CSS`.
```
node-sass --output-style expanded --precision 6 $1 $css_file
```
and run as above.
* If you're building from source, copy the minified css to `<jf-accounts git directory>/jellyfin_accounts/data/static/bs<4/5>-jf.css`.
* If you're just customizing your install, set `custom_css` in your config as the path to your minified css and change the `theme` option to `Custom CSS`.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -120,6 +120,10 @@ $list-group-action-active-bg: $jf-blue-focus;
color: $jf-text-bold; color: $jf-text-bold;
} }
.icon-button {
color: $text-muted;
}
.text-bright { .text-bright {
color: $jf-text-bold; color: $jf-text-bold;
} }

View File

@@ -1,10 +0,0 @@
#!/bin/bash
css_file=$(echo $1 | sed 's/scss/css/g')
min_file=$(echo $1 | sed 's/scss/min.css/g')
sassc -t expanded -p 6 $1 $css_file
echo "Compiled."
postcss $css_file --replace --use autoprefixer
echo "Prefixed."
echo "Written to $css_file."
cleancss --level 1 --format breakWith=lf --source-map --source-map-inline-sources --output $min_file $css_file
echo "Minified version written to $min_file."

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -120,6 +120,10 @@ $list-group-action-active-bg: $jf-blue-focus;
color: $jf-text-bold; color: $jf-text-bold;
} }
.icon-button:active {
color: $text-muted;
}
.text-bright { .text-bright {
color: $jf-text-bold; color: $jf-text-bold;
} }

View File

@@ -1,10 +0,0 @@
#!/bin/bash
css_file=$(echo $1 | sed 's/scss/css/g')
min_file=$(echo $1 | sed 's/scss/min.css/g')
sassc -t expanded -p 6 $1 $css_file
echo "Compiled."
postcss $css_file --replace --use autoprefixer
echo "Prefixed."
echo "Written to $css_file."
cleancss --level 1 --format breakWith=lf --source-map --source-map-inline-sources --output $min_file $css_file
echo "Minified version written to $min_file."

65
scss/compile.py Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import sass
import subprocess
import shutil
import os
import argparse
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument(
"-y", "--yes", help="use assumed node bin directory.", action="store_true"
)
def runcmd(cmd):
if os.name == "nt":
return subprocess.check_output(cmd, shell=True)
proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
return proc.communicate()
local_path = Path(__file__).resolve().parent
out = runcmd("npm bin")
try:
node_bin = Path(out[0].decode('utf-8').rstrip())
except:
node_bin = Path(out.decode('utf-8').rstrip())
args = parser.parse_args()
if not args.yes:
print(f"assuming npm bin directory \"{node_bin}\". Is this correct?")
if input("[yY/nN]: ").lower() == "n":
node_bin = local_path.parent / 'node_modules' / '.bin'
print(f"this? \"{node_bin}\"")
if input("[yY/nN]: ").lower() == "n":
node_bin = input("input bin directory: ")
for bsv in [d for d in local_path.iterdir() if 'bs' in d.name]:
scss = bsv / f'{bsv.name}-jf.scss'
css = bsv / f'{bsv.name}-jf.css'
min_css = bsv.parents[1] / 'jellyfin_accounts' / 'data' / 'static' / f'{bsv.name}-jf.css'
with open(css, 'w') as f:
f.write(sass.compile(filename=str(scss.resolve()),
output_style='expanded',
precision=6))
if css.exists():
print(f'{bsv.name}: Compiled.')
# postcss only excepts forwards slashes? weird.
cssPath = str(css.resolve())
if os.name == 'nt':
cssPath = cssPath.replace('\\', '/')
runcmd(f'{str((node_bin / "postcss").resolve())} {cssPath} --replace --use autoprefixer')
print(f'{bsv.name}: Prefixed.')
runcmd(f'{str((node_bin / "cleancss").resolve())} --level 1 --format breakWith=lf --output {str(min_css.resolve())} {str(css.resolve())}')
if min_css.exists():
print(f'{bsv.name}: Minified and copied to {str(min_css.resolve())}.')
for v in [('bootstrap', 'bs5'), ('bootstrap4', 'bs4')]:
new_path = str((local_path.parent / 'jellyfin_accounts' / 'data' / 'static' / (v[1] + '.css')).resolve())
shutil.copy(str((local_path.parent / 'node_modules' / v[0] / 'dist' / 'css' / 'bootstrap.min.css').resolve()),
new_path)
print(f'Copied {v[1]} to {new_path}')

25
scss/get_node_deps.py Normal file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python3
import subprocess
import os
from pathlib import Path
def runcmd(cmd):
if os.name == "nt":
return subprocess.check_output(cmd, shell=True)
proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
return proc.communicate()
print('Installing npm packages')
root_path = Path(__file__).parents[1]
if os.name == 'nt':
root_path /= 'node_modules'
runcmd(f'npm install')
if (root_path / 'node_modules' / 'cleancss').exists():
print(f'Installed successfully in {str((root_path / "node_modules").resolve())}.')