mirror of
https://github.com/hrfee/jellyfin-accounts.git
synced 2025-12-19 16:51:13 +00:00
Compare commits
24 Commits
modular-st
...
dynamic-se
| Author | SHA1 | Date | |
|---|---|---|---|
| ac60cc37da | |||
| 0bb54d1c45 | |||
| 8e94f04d5a | |||
| eb8e04d5a2 | |||
| 52a11c3905 | |||
| 52f9b5c963 | |||
| 55d26b541a | |||
| 4606415a38 | |||
| 00ba11940a | |||
| 9532f24e7a | |||
|
|
95e5d8fb3d | ||
|
|
3f1b2ad4a8 | ||
| b775e36171 | |||
| 68a459023c | |||
| 09bbe8fddf | |||
| 99c34d7916 | |||
| db1b707ec1 | |||
| 4809331502 | |||
| 24045034c8 | |||
| 079dff8d9f | |||
| b943bd1f27 | |||
| 267a47087c | |||
| 4d6872dc17 | |||
| 4372c9d12f |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,11 +4,13 @@ MANIFEST.in
|
||||
dist/
|
||||
build/
|
||||
test.txt
|
||||
data/node_modules/
|
||||
jellyfin_accounts/data/node_modules/
|
||||
jellyfin_accounts/data/config-default.ini
|
||||
*.egg-info/
|
||||
pw-reset/
|
||||
jfa/
|
||||
colors.txt
|
||||
theme.css
|
||||
data/static/bootstrap-jf.css
|
||||
jellyfin_accounts/data/static/bootstrap-jf.css
|
||||
old/
|
||||
.jf-accounts/
|
||||
|
||||
148
README.md
148
README.md
@@ -1,4 +1,4 @@
|
||||
# 
|
||||
# 
|
||||
|
||||
A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin).
|
||||
* Provides a web interface for creating invite codes, and a simple account creation form
|
||||
@@ -9,12 +9,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)
|
||||
## Interface
|
||||
<p align="center">
|
||||
<img src="images/jfa.gif" width="100%"></img>
|
||||
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/jfa.gif" width="100%"></img>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="images/admin.png" width="48%" style="margin-right: 1.5%;" alt="Admin page"></img>
|
||||
<img src="images/create.png" width="48%" style="margin-left: 1.5%;" alt="Account creation page"></img>
|
||||
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/jfa.gif" 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>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -31,26 +31,22 @@ A basic account management system for [Jellyfin](https://github.com/jellyfin/jel
|
||||
* requests
|
||||
* itsdangerous
|
||||
* passlib
|
||||
* secrets
|
||||
* configparser
|
||||
* pyOpenSSL
|
||||
* waitress
|
||||
* pytz
|
||||
* python-dateutil
|
||||
* watchdog
|
||||
* packaging
|
||||
```
|
||||
### Install
|
||||
|
||||
Usually as simple as:
|
||||
```
|
||||
git clone https://github.com/hrfee/jellyfin-accounts.git
|
||||
cd jellyfin-accounts
|
||||
pip3 install pyOpenSSL
|
||||
python3 setup.py install
|
||||
pip install jellyfin-accounts
|
||||
```
|
||||
If not, or if you want to use docker, see [install](https://github.com/hrfee/jellyfin-accounts/wiki/Install).
|
||||
|
||||
### Usage
|
||||
## Usage
|
||||
* Passing no arguments will run the server
|
||||
```
|
||||
usage: jf-accounts [-h] [-c CONFIG] [-d DATA] [--host HOST] [-p PORT] [-g]
|
||||
@@ -69,142 +65,26 @@ optional arguments:
|
||||
and homescreen layout and output it as json to be used
|
||||
as a user template.
|
||||
```
|
||||
### Setup
|
||||
## Setup
|
||||
#### New user template
|
||||
* You may want to restrict a user from accessing certain libraries (e.g 4K Movies), display their account on the login screen by default, or set a default homecrseen layout. Jellyfin stores these settings in the user's policy, configuration and displayPreferences.
|
||||
* Make a temporary account and change its settings, then run `jf-accounts --get_defaults`. Choose your user, and this data will be stored at the location you set in `user_template`, `user_configuration` and `user_displayprefs` (or their default locations), and used for all subsequent new accounts.
|
||||
* Make a temporary account and configure it, then in the web UI, go into "Settings => Set new account defaults". Choose the account, and its configuration will be stored for future use.
|
||||
#### Emails/Password Resets
|
||||
* When someone initiates forget password on Jellyfin, a file named `passwordreset*.json` is created in its configuration directory. This directory is monitored and when created, the program reads the username, expiry time and PIN, puts it into a template and sends it to whatever address is specified in `emails.json`.
|
||||
* **The default forget password popup references the `passwordreset*.json` file created. This is confusing for users, so a quick fix is to edit the `MessageForgotPasswordFileCreated` string in Jellyfin's language folder.**
|
||||
* Currently, jellyfin-accounts supports generic SSL/TLS or STARTTLS secured SMTP, and the [mailgun](https://mailgun.com) REST API.
|
||||
* Email html is created using [mjml](https://mjml.io), and [jinja](https://github.com/pallets/jinja) templating is used. If you wish to create your own, ensure you use the same jinja expressions (`{{ pin }}`, etc.) as used in `data/email.mjml` or `invite-email.mjml`, and also create plain text versions for legacy email clients.
|
||||
|
||||
#### Configuration
|
||||
### Configuration
|
||||
* Note: Make sure to put this behind a reverse proxy with HTTPS.
|
||||
|
||||
On first run, access the setup wizard at `0.0.0.0:8056`. When finished, restart the program.
|
||||
|
||||
The configuration is stored at `~/.jf-accounts/config.ini`.
|
||||
The configuration is stored at `~/.jf-accounts/config.ini`. Settings can be changed through the web UI, or by manually editing the file.
|
||||
|
||||
For detailed descriptions of each setting, see [setup](https://github.com/hrfee/jellyfin-accounts/wiki/Setup).
|
||||
|
||||
### Donations
|
||||
I strongly suggest you send your money to [Jellyfin](https://opencollective.com/jellyfin) or a good charity, but for those who want to help me out, a Paypal link is below.
|
||||
|
||||
```
|
||||
[jellyfin]
|
||||
; It is reccommended to create a limited admin account for this program.
|
||||
username = username
|
||||
password = password
|
||||
; Jellyfin server address. Can be public, or local for security purposes.
|
||||
server = http://jellyfin.local:8096
|
||||
; Publicly accessible Jellyfin address, used on invite form.
|
||||
; Leave blank to use the same address as above.
|
||||
public_server = https://jellyf.in:443
|
||||
client = jf-accounts
|
||||
version = 0.1
|
||||
device = jf-accounts
|
||||
device_id = jf-accounts-0.1
|
||||
|
||||
[ui]
|
||||
; Set 0.0.0.0 to run localhost
|
||||
host = 0.0.0.0
|
||||
port = 8056
|
||||
; Enable this to use Jellyfin users instead of the below username and pw.
|
||||
jellyfin_login = true
|
||||
; Allows only admin users on Jellyfin to access admin page.
|
||||
admin_only = true
|
||||
; Username to use on admin page... (leave blank if using jellyfin_login)
|
||||
username = your username
|
||||
; ..and its corresponding password (leave blank if using jellyfin_login)
|
||||
password = your password
|
||||
|
||||
debug = false
|
||||
|
||||
; Displayed at the bottom of all pages except admin
|
||||
contact_message = Need help? contact me.
|
||||
; Displayed at top of form page.
|
||||
help_message = Enter your details to create an account.
|
||||
; Displayed when an account is created.
|
||||
success_message = Your account has been created. Click below to continue to Jellyfin.
|
||||
|
||||
[password_validation]
|
||||
; Enables password validation.
|
||||
enabled = true
|
||||
; Min. password length
|
||||
min_length = 8
|
||||
; Min. number of uppercase characters
|
||||
upper = 1
|
||||
; Min. number of lowercase characters
|
||||
lower = 0
|
||||
; Min. number of numbers
|
||||
number = 1
|
||||
; Min. number of special characters
|
||||
special = 0
|
||||
|
||||
[email]
|
||||
; Leave this whole section if you aren't using any email-related features.
|
||||
use_24h = true
|
||||
; Date format follows datetime's strftime.
|
||||
date_format = %d/%m/%y
|
||||
; Displayed at bottom of emails
|
||||
message = Need help? contact me.
|
||||
; Mail methods: mailgun, smtp
|
||||
method = smtp
|
||||
; Address to send from
|
||||
address = jellyfin@jellyf.in
|
||||
; The name of the sender
|
||||
from = Jellyfin
|
||||
|
||||
[password_resets]
|
||||
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
|
||||
enabled = true
|
||||
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
|
||||
watch_directory = /path/to/jellyfin
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_html =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_text =
|
||||
; Subject of emails
|
||||
subject = Password Reset - Jellyfin
|
||||
|
||||
[invite_emails]
|
||||
; If enabled, allows one to send an invite directly to an email address.
|
||||
enabled = true
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_html =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_text =
|
||||
subject = Invite - Jellyfin
|
||||
; Base url for jf-accounts. This necessary because most will use a reverse proxy, so the program has no other way of knowing what URL to send.
|
||||
url_base = http://accounts.jellyf.in:8056/invite
|
||||
|
||||
[mailgun]
|
||||
|
||||
api_url = https://api.mailgun.net...
|
||||
api_key = your api key
|
||||
|
||||
[smtp]
|
||||
; Choose between ssl_tls and starttls. Your provider should tell you which to use, but generally SSL/TLS is 465, STARTTLS 587
|
||||
encryption = starttls
|
||||
server = smtp.jellyf.in
|
||||
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
|
||||
port = 587
|
||||
password = smtp password
|
||||
|
||||
[files]
|
||||
; When the below paths are left blank, files are stored in ~/.jf-accounts/.
|
||||
|
||||
; Path to store valid invites.
|
||||
invites =
|
||||
; Path to store emails addresses in JSON
|
||||
emails =
|
||||
; Path to the user policy template. Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_template =
|
||||
; Path to the user configuration template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_configuration =
|
||||
; Path to the user display preferences template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_displayprefs =
|
||||
; Path to custom bootstrap.css
|
||||
custom_css =
|
||||
```
|
||||
|
||||
|
||||
[Donate](https://www.paypal.me/hrfee)
|
||||
|
||||
115
config-default.ini
Normal file
115
config-default.ini
Normal file
@@ -0,0 +1,115 @@
|
||||
[jellyfin]
|
||||
; settings for connecting to jellyfin
|
||||
; it is recommended to create a limited admin account for this program.
|
||||
username = username
|
||||
password = password
|
||||
; jellyfin server address. can be public, or local for security purposes.
|
||||
server = http://jellyfin.local:8096
|
||||
; publicly accessible jellyfin address for invite form. leave blank to reuse the above address.
|
||||
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.
|
||||
client = jf-accounts
|
||||
version = 0.2.5
|
||||
device = jf-accounts
|
||||
device_id = jf-accounts-0.2.5
|
||||
|
||||
[ui]
|
||||
; settings related to the ui and program functionality.
|
||||
; set 0.0.0.0 to run on localhost
|
||||
host = 0.0.0.0
|
||||
port = 8056
|
||||
; enable this to use jellyfin users instead of the below username and pw.
|
||||
jellyfin_login = true
|
||||
; allows only admin users on jellyfin to access the admin page.
|
||||
admin_only = true
|
||||
; username for admin page (leave blank if using jellyfin_login)
|
||||
username = your username
|
||||
; password for admin page (leave blank if using jellyfin_login)
|
||||
password = your password
|
||||
debug = false
|
||||
; displayed at bottom of all pages except admin
|
||||
contact_message = Need help? contact me.
|
||||
; display at top of invite form.
|
||||
help_message = Enter your details to create an account.
|
||||
; displayed when a user creates an account
|
||||
success_message = Your account has been created. Click below to continue to Jellyfin.
|
||||
|
||||
[password_validation]
|
||||
; password validation (minimum length, etc.)
|
||||
enabled = true
|
||||
min_length = 8
|
||||
upper = 1
|
||||
lower = 0
|
||||
number = 1
|
||||
special = 0
|
||||
|
||||
[email]
|
||||
; general email settings. ignore if not using email features.
|
||||
; use email address from invite form as username on jellyfin.
|
||||
no_username = false
|
||||
use_24h = true
|
||||
; date format used in emails. follows datetime.strftime format.
|
||||
date_format = %d/%m/%y
|
||||
; message displayed at bottom of emails.
|
||||
message = Need help? contact me.
|
||||
; method of sending email to use.
|
||||
method = smtp
|
||||
; address to send emails from
|
||||
address = jellyfin@jellyf.in
|
||||
; the name of the sender
|
||||
from = Jellyfin
|
||||
|
||||
[password_resets]
|
||||
; settings for the password reset handler.
|
||||
; enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send reset pins
|
||||
enabled = true
|
||||
; path to the folder jellyfin puts password-reset files.
|
||||
watch_directory = /path/to/jellyfin
|
||||
; path to custom email html
|
||||
email_html =
|
||||
; path to custom email in plain text
|
||||
email_text =
|
||||
; subject of password reset emails.
|
||||
subject = Password Reset - Jellyfin
|
||||
|
||||
[invite_emails]
|
||||
; settings for sending invites directly to users.
|
||||
enabled = true
|
||||
; path to custom email html
|
||||
email_html =
|
||||
; path to custom email in plain text
|
||||
email_text =
|
||||
; subject of invite emails.
|
||||
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.
|
||||
url_base = http://accounts.jellyf.in:8056/invite
|
||||
|
||||
[mailgun]
|
||||
; mailgun api connection settings
|
||||
api_url = https://api.mailgun.net...
|
||||
api_key = your api key
|
||||
|
||||
[smtp]
|
||||
; smtp server connection settings.
|
||||
; your email provider should provide different ports for each encryption method. generally 465 for ssl_tls, 587 for starttls.
|
||||
encryption = starttls
|
||||
; smtp server address.
|
||||
server = smtp.jellyf.in
|
||||
port = 465
|
||||
password = smtp password
|
||||
|
||||
[files]
|
||||
; optional settings for changing storage locations.
|
||||
; location of stored invites (json).
|
||||
invites =
|
||||
; location of stored email addresses (json).
|
||||
emails =
|
||||
; location of stored user policy template (json).
|
||||
user_template =
|
||||
; location of stored user configuration template (used for setting homescreen layout) (json)
|
||||
user_configuration =
|
||||
; location of stored displaypreferences template (also used for homescreen layout) (json)
|
||||
user_displayprefs =
|
||||
; location of custom bootstrap css.
|
||||
custom_css =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
__version__ = "0.2"
|
||||
__version__ = "0.2.6"
|
||||
|
||||
import secrets
|
||||
import configparser
|
||||
@@ -11,194 +11,259 @@ import signal
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from flask import Flask, g
|
||||
from flask import Flask, jsonify, g
|
||||
from jellyfin_accounts.data_store import JSONStorage
|
||||
|
||||
parser = argparse.ArgumentParser(description="jellyfin-accounts")
|
||||
|
||||
parser.add_argument("-c", "--config",
|
||||
help="specifies path to configuration file.")
|
||||
parser.add_argument("-d", "--data",
|
||||
help=("specifies directory to store data in. " +
|
||||
"defaults to ~/.jf-accounts."))
|
||||
parser.add_argument("--host",
|
||||
help="address to host web ui on.")
|
||||
parser.add_argument("-p", "--port",
|
||||
help="port to host web ui on.")
|
||||
parser.add_argument("-g", "--get_defaults",
|
||||
help=("tool to grab a JF users " +
|
||||
"policy (access, perms, etc.) and " +
|
||||
"homescreen layout and " +
|
||||
"output it as json to be used as a user template."),
|
||||
action='store_true')
|
||||
parser.add_argument("-c", "--config", help="specifies path to configuration file.")
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--data",
|
||||
help=("specifies directory to store data in. " + "defaults to ~/.jf-accounts."),
|
||||
)
|
||||
parser.add_argument("--host", help="address to host web ui on.")
|
||||
parser.add_argument("-p", "--port", help="port to host web ui on.")
|
||||
parser.add_argument(
|
||||
"-g",
|
||||
"--get_defaults",
|
||||
help=(
|
||||
"tool to grab a JF users "
|
||||
+ "policy (access, perms, etc.) and "
|
||||
+ "homescreen layout and "
|
||||
+ "output it as json to be used as a user template."
|
||||
),
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
args, leftovers = parser.parse_known_args()
|
||||
|
||||
if args.data is not None:
|
||||
data_dir = Path(args.data)
|
||||
else:
|
||||
data_dir = Path.home() / '.jf-accounts'
|
||||
data_dir = Path.home() / ".jf-accounts"
|
||||
|
||||
local_dir = (Path(__file__).parent / 'data').resolve()
|
||||
local_dir = (Path(__file__).parent / "data").resolve()
|
||||
config_base_path = local_dir / "config-base.json"
|
||||
|
||||
first_run = False
|
||||
if data_dir.exists() is False or (data_dir / 'config.ini').exists() is False:
|
||||
if data_dir.exists() is False or (data_dir / "config.ini").exists() is False:
|
||||
if not data_dir.exists():
|
||||
Path.mkdir(data_dir)
|
||||
print(f'Config dir not found, so created at {str(data_dir)}')
|
||||
print(f"Config dir not found, so generating at {str(data_dir)}")
|
||||
if args.config is None:
|
||||
config_path = data_dir / 'config.ini'
|
||||
shutil.copy(str(local_dir / 'config-default.ini'),
|
||||
str(config_path))
|
||||
config_path = data_dir / "config.ini"
|
||||
from jellyfin_accounts.generate_ini import generate_ini
|
||||
|
||||
default_path = local_dir / "config-default.ini"
|
||||
generate_ini(config_base_path, default_path, __version__)
|
||||
shutil.copy(str(default_path), str(config_path))
|
||||
print("Setup through the web UI, or quit and edit the configuration manually.")
|
||||
first_run = True
|
||||
else:
|
||||
config_path = Path(args.config)
|
||||
print(f'config.ini can be found at {str(config_path)}')
|
||||
print(f"config.ini can be found at {str(config_path)}")
|
||||
else:
|
||||
config_path = data_dir / 'config.ini'
|
||||
config_path = data_dir / "config.ini"
|
||||
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
temp_config = configparser.RawConfigParser()
|
||||
temp_config.read(config_path)
|
||||
|
||||
def create_log(name):
|
||||
log = logging.getLogger(name)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
if config.getboolean('ui', 'debug'):
|
||||
if temp_config.getboolean('ui', 'debug'):
|
||||
log.setLevel(logging.DEBUG)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
else:
|
||||
log.setLevel(logging.INFO)
|
||||
handler.setLevel(logging.INFO)
|
||||
fmt = ' %(name)s - %(levelname)s - %(message)s'
|
||||
fmt = " %(name)s - %(levelname)s - %(message)s"
|
||||
format = logging.Formatter(fmt)
|
||||
handler.setFormatter(format)
|
||||
log.addHandler(handler)
|
||||
log.propagate = False
|
||||
return log
|
||||
|
||||
log = create_log('main')
|
||||
web_log = create_log('waitress')
|
||||
|
||||
log = create_log("main")
|
||||
|
||||
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"]
|
||||
return config
|
||||
|
||||
config = load_config(config_path, data_dir)
|
||||
|
||||
web_log = create_log("waitress")
|
||||
if not first_run:
|
||||
email_log = create_log('emails')
|
||||
auth_log = create_log('auth')
|
||||
email_log = create_log("emails")
|
||||
auth_log = create_log("auth")
|
||||
|
||||
if args.host is not None:
|
||||
log.debug(f'Using specified host {args.host}')
|
||||
config['ui']['host'] = args.host
|
||||
log.debug(f"Using specified host {args.host}")
|
||||
config["ui"]["host"] = args.host
|
||||
if args.port is not None:
|
||||
log.debug(f'Using specified port {args.port}')
|
||||
config['ui']['port'] = args.port
|
||||
|
||||
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'))
|
||||
|
||||
with open(config['files']['invites'], 'r') as f:
|
||||
temp_invites = json.load(f)
|
||||
if 'invites' in temp_invites:
|
||||
new_invites = {}
|
||||
log.info('Converting invites.json to new format, temporary.')
|
||||
for el in temp_invites['invites']:
|
||||
i = {'valid_till': el['valid_till']}
|
||||
if 'email' in el:
|
||||
i['email'] = el['email']
|
||||
new_invites[el['code']] = i
|
||||
with open(config['files']['invites'], 'w') as f:
|
||||
f.write(json.dumps(new_invites, indent=4, default=str))
|
||||
log.debug(f"Using specified port {args.port}")
|
||||
config["ui"]["port"] = args.port
|
||||
|
||||
|
||||
data_store = JSONStorage(config['files']['emails'],
|
||||
config['files']['invites'],
|
||||
config['files']['user_template'],
|
||||
config['files']['user_displayprefs'],
|
||||
config['files']['user_configuration'])
|
||||
try:
|
||||
with open(config["files"]["invites"], "r") as f:
|
||||
temp_invites = json.load(f)
|
||||
if "invites" in temp_invites:
|
||||
new_invites = {}
|
||||
log.info("Converting invites.json to new format, temporary.")
|
||||
for el in temp_invites["invites"]:
|
||||
i = {"valid_till": el["valid_till"]}
|
||||
if "email" in el:
|
||||
i["email"] = el["email"]
|
||||
new_invites[el["code"]] = i
|
||||
with open(config["files"]["invites"], "w") as f:
|
||||
f.write(json.dumps(new_invites, indent=4, default=str))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
data_store = JSONStorage(
|
||||
config["files"]["emails"],
|
||||
config["files"]["invites"],
|
||||
config["files"]["user_template"],
|
||||
config["files"]["user_displayprefs"],
|
||||
config["files"]["user_configuration"],
|
||||
)
|
||||
|
||||
|
||||
def default_css():
|
||||
css = {}
|
||||
css['href'] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
|
||||
css['integrity'] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
|
||||
css['crossorigin'] = "anonymous"
|
||||
css[
|
||||
"href"
|
||||
] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
|
||||
css[
|
||||
"integrity"
|
||||
] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
|
||||
css["crossorigin"] = "anonymous"
|
||||
return css
|
||||
|
||||
|
||||
css = {}
|
||||
css = default_css()
|
||||
if 'custom_css' in config['files']:
|
||||
if config['files']['custom_css'] != '':
|
||||
if "custom_css" in config["files"]:
|
||||
if config["files"]["custom_css"] != "":
|
||||
try:
|
||||
shutil.copy(config['files']['custom_css'],
|
||||
(local_dir / 'static' / 'bootstrap.css'))
|
||||
log.debug('Loaded custom CSS')
|
||||
css['href'] = '/bootstrap.css'
|
||||
css['integrity'] = ''
|
||||
css['crossorigin'] = ''
|
||||
shutil.copy(
|
||||
config["files"]["custom_css"], (local_dir / "static" / "bootstrap.css")
|
||||
)
|
||||
log.debug("Loaded custom CSS")
|
||||
css["href"] = "/bootstrap.css"
|
||||
css["integrity"] = ""
|
||||
css["crossorigin"] = ""
|
||||
except FileNotFoundError:
|
||||
log.error(f'Custom CSS {config["files"]["custom_css"]} not found, using default.')
|
||||
log.error(
|
||||
f'Custom CSS {config["files"]["custom_css"]} not found, using default.'
|
||||
)
|
||||
|
||||
|
||||
if ('email_html' not in config['password_resets'] or
|
||||
config['password_resets']['email_html'] == ''):
|
||||
log.debug('Using default password reset email HTML template')
|
||||
config['password_resets']['email_html'] = str(local_dir / 'email.html')
|
||||
if ('email_text' not in config['password_resets'] or
|
||||
config['password_resets']['email_text'] == ''):
|
||||
log.debug('Using default password reset email plaintext template')
|
||||
config['password_resets']['email_text'] = str(local_dir / 'email.txt')
|
||||
|
||||
if ('email_html' not in config['invite_emails'] or
|
||||
config['invite_emails']['email_html'] == ''):
|
||||
log.debug('Using default invite email HTML template')
|
||||
config['invite_emails']['email_html'] = str(local_dir /
|
||||
'invite-email.html')
|
||||
if ('email_text' not in config['invite_emails'] or
|
||||
config['invite_emails']['email_text'] == ''):
|
||||
log.debug('Using default invite email plaintext template')
|
||||
config['invite_emails']['email_text'] = str(local_dir /
|
||||
'invite-email.txt')
|
||||
if ('public_server' not in config['jellyfin'] or
|
||||
config['jellyfin']['public_server'] == ''):
|
||||
config['jellyfin']['public_server'] = config['jellyfin']['server']
|
||||
|
||||
def resp(success=True, code=500):
|
||||
if success:
|
||||
r = jsonify({"success": True})
|
||||
if code == 500:
|
||||
r.status_code = 200
|
||||
else:
|
||||
r.status_code = code
|
||||
else:
|
||||
r = jsonify({"success": False})
|
||||
r.status_code = code
|
||||
return r
|
||||
|
||||
|
||||
def main():
|
||||
if args.get_defaults:
|
||||
import json
|
||||
from jellyfin_accounts.jf_api import Jellyfin
|
||||
jf = Jellyfin(config['jellyfin']['server'],
|
||||
config['jellyfin']['client'],
|
||||
config['jellyfin']['version'],
|
||||
config['jellyfin']['device'],
|
||||
config['jellyfin']['device_id'])
|
||||
|
||||
jf = Jellyfin(
|
||||
config["jellyfin"]["server"],
|
||||
config["jellyfin"]["client"],
|
||||
config["jellyfin"]["version"],
|
||||
config["jellyfin"]["device"],
|
||||
config["jellyfin"]["device_id"],
|
||||
)
|
||||
print("NOTE: This can now be done through the web ui.")
|
||||
print("""
|
||||
print(
|
||||
"""
|
||||
This tool lets you grab various settings from a user,
|
||||
so that they can be applied every time a new account is
|
||||
created. """)
|
||||
created. """
|
||||
)
|
||||
print("Step 1: User Policy.")
|
||||
print("""
|
||||
A user policy stores a users permissions (e.g access rights and
|
||||
print(
|
||||
"""
|
||||
A user policy stores a users permissions (e.g access rights and
|
||||
most of the other settings in the 'Profile' and 'Access' tabs
|
||||
of a user). """)
|
||||
of a user). """
|
||||
)
|
||||
success = False
|
||||
msg = "Get public users only or all users? (requires auth) [public/all]: "
|
||||
msg = "Get public users only or all users? (requires auth) [public/all]: "
|
||||
public = False
|
||||
while not success:
|
||||
choice = input(msg)
|
||||
if choice == 'public':
|
||||
if choice == "public":
|
||||
public = True
|
||||
print("Make sure the user is publicly visible!")
|
||||
success = True
|
||||
elif choice == 'all':
|
||||
jf.authenticate(config['jellyfin']['username'],
|
||||
config['jellyfin']['password'])
|
||||
elif choice == "all":
|
||||
jf.authenticate(
|
||||
config["jellyfin"]["username"], config["jellyfin"]["password"]
|
||||
)
|
||||
public = False
|
||||
success = True
|
||||
users = jf.getUsers(public=public)
|
||||
@@ -207,69 +272,79 @@ def main():
|
||||
success = False
|
||||
while not success:
|
||||
try:
|
||||
user_index = int(input(">: "))-1
|
||||
policy = users[user_index]['Policy']
|
||||
user_index = int(input(">: ")) - 1
|
||||
policy = users[user_index]["Policy"]
|
||||
success = True
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
data_store.user_template = policy
|
||||
print(f'Policy written to "{config["files"]["user_template"]}".')
|
||||
print('In future, this policy will be copied to all new users.')
|
||||
print('Step 2: Homescreen Layout')
|
||||
print("""
|
||||
print("In future, this policy will be copied to all new users.")
|
||||
print("Step 2: Homescreen Layout")
|
||||
print(
|
||||
"""
|
||||
You may want to customize the default layout of a new user's
|
||||
home screen. These settings can be applied to an account through
|
||||
the 'Home' section in a user's settings. """)
|
||||
the 'Home' section in a user's settings. """
|
||||
)
|
||||
success = False
|
||||
while not success:
|
||||
choice = input("Grab the chosen user's homescreen layout? [y/n]: ")
|
||||
if choice.lower() == 'y':
|
||||
user_id = users[user_index]['Id']
|
||||
configuration = users[user_index]['Configuration']
|
||||
if choice.lower() == "y":
|
||||
user_id = users[user_index]["Id"]
|
||||
configuration = users[user_index]["Configuration"]
|
||||
display_prefs = jf.getDisplayPreferences(user_id)
|
||||
data_store.user_configuration = configuration
|
||||
print(f'Configuration written to "{config["files"]["user_configuration"]}".')
|
||||
print(
|
||||
f'Configuration written to "{config["files"]["user_configuration"]}".'
|
||||
)
|
||||
data_store.user_displayprefs = display_prefs
|
||||
print(f'Display Prefs written to "{config["files"]["user_displayprefs"]}".')
|
||||
print(
|
||||
f'Display Prefs written to "{config["files"]["user_displayprefs"]}".'
|
||||
)
|
||||
success = True
|
||||
elif choice.lower() == 'n':
|
||||
elif choice.lower() == "n":
|
||||
success = True
|
||||
|
||||
else:
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print('Quitting...')
|
||||
print("Quitting...")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
global app
|
||||
app = Flask(__name__, root_path=str(local_dir))
|
||||
app.config['DEBUG'] = config.getboolean('ui', 'debug')
|
||||
app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
|
||||
app.config["DEBUG"] = config.getboolean("ui", "debug")
|
||||
app.config["SECRET_KEY"] = secrets.token_urlsafe(16)
|
||||
app.config["JSON_SORT_KEYS"] = False
|
||||
|
||||
from waitress import serve
|
||||
|
||||
if first_run:
|
||||
import jellyfin_accounts.setup
|
||||
host = config['ui']['host']
|
||||
port = config['ui']['port']
|
||||
log.info('Starting web UI for first run setup...')
|
||||
serve(app,
|
||||
host=host,
|
||||
port=port)
|
||||
|
||||
host = config["ui"]["host"]
|
||||
port = config["ui"]["port"]
|
||||
log.info("Starting web UI for first run setup...")
|
||||
serve(app, host=host, port=port)
|
||||
else:
|
||||
import jellyfin_accounts.web_api
|
||||
import jellyfin_accounts.web
|
||||
host = config['ui']['host']
|
||||
port = config['ui']['port']
|
||||
log.info(f'Starting web UI on {host}:{port}')
|
||||
if config.getboolean('password_resets', 'enabled'):
|
||||
|
||||
host = config["ui"]["host"]
|
||||
port = config["ui"]["port"]
|
||||
log.info(f"Starting web UI on {host}:{port}")
|
||||
if config.getboolean("password_resets", "enabled"):
|
||||
|
||||
def start_pwr():
|
||||
import jellyfin_accounts.pw_reset
|
||||
|
||||
jellyfin_accounts.pw_reset.start()
|
||||
|
||||
pwr = threading.Thread(target=start_pwr, daemon=True)
|
||||
log.info('Starting email thread')
|
||||
log.info("Starting email thread")
|
||||
pwr.start()
|
||||
|
||||
serve(app,
|
||||
host=host,
|
||||
port=int(port))
|
||||
serve(app, host=host, port=int(port))
|
||||
|
||||
487
jellyfin_accounts/data/config-base.json
Normal file
487
jellyfin_accounts/data/config-base.json
Normal file
@@ -0,0 +1,487 @@
|
||||
{
|
||||
"jellyfin": {
|
||||
"meta": {
|
||||
"name": "Jellyfin",
|
||||
"description": "Settings for connecting to Jellyfin"
|
||||
},
|
||||
"username": {
|
||||
"name": "Jellyfin Username",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "username",
|
||||
"description": "It is recommended to create a limited admin account for this program."
|
||||
},
|
||||
"password": {
|
||||
"name": "Jellyfin Password",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "password",
|
||||
"value": "password"
|
||||
},
|
||||
"server": {
|
||||
"name": "Server address",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "http://jellyfin.local:8096",
|
||||
"description": "Jellyfin server address. Can be public, or local for security purposes."
|
||||
},
|
||||
"public_server": {
|
||||
"name": "Public address",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "https://jellyf.in:443",
|
||||
"description": "Publicly accessible Jellyfin address for invite form. Leave blank to reuse the above address."
|
||||
},
|
||||
"client": {
|
||||
"name": "Client Name",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "jf-accounts",
|
||||
"description": "This and below settings will show on the Jellyfin dashboard when the program connects. You may as well leave them alone."
|
||||
},
|
||||
"version": {
|
||||
"name": "Version Number",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "{version}"
|
||||
},
|
||||
"device": {
|
||||
"name": "Device Name",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "jf-accounts"
|
||||
},
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "jf-accounts-{version}"
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"meta": {
|
||||
"name": "General",
|
||||
"description": "Settings related to the UI and program functionality."
|
||||
},
|
||||
"host": {
|
||||
"name": "Address",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "0.0.0.0",
|
||||
"description": "Set 0.0.0.0 to run on localhost"
|
||||
},
|
||||
"port": {
|
||||
"name": "Port",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 8056
|
||||
},
|
||||
"jellyfin_login": {
|
||||
"name": "Use Jellyfin for authentication",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Enable this to use Jellyfin users instead of the below username and pw."
|
||||
},
|
||||
"admin_only": {
|
||||
"name": "Allow admin users only",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "jellyfin_login",
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Allows only admin users on Jellyfin to access the admin page."
|
||||
},
|
||||
"username": {
|
||||
"name": "Web Username",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"depends_false": "jellyfin_login",
|
||||
"type": "text",
|
||||
"value": "your username",
|
||||
"description": "Username for admin page (Leave blank if using jellyfin_login)"
|
||||
},
|
||||
"password": {
|
||||
"name": "Web Password",
|
||||
"required": true,
|
||||
"requires_restart": true,
|
||||
"depends_false": "jellyfin_login",
|
||||
"type": "password",
|
||||
"value": "your password",
|
||||
"description": "Password for admin page (Leave blank if using jellyfin_login)"
|
||||
},
|
||||
"debug": {
|
||||
"name": "Debug logging",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false
|
||||
},
|
||||
"contact_message": {
|
||||
"name": "Contact message",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "Need help? contact me.",
|
||||
"description": "Displayed at bottom of all pages except admin"
|
||||
},
|
||||
"help_message": {
|
||||
"name": "Help message",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "Enter your details to create an account.",
|
||||
"description": "Display at top of invite form."
|
||||
},
|
||||
"success_message": {
|
||||
"name": "Success message",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "Your account has been created. Click below to continue to Jellyfin.",
|
||||
"description": "Displayed when a user creates an account"
|
||||
}
|
||||
},
|
||||
"password_validation": {
|
||||
"meta": {
|
||||
"name": "Password Validation",
|
||||
"description": "Password validation (minimum length, etc.)"
|
||||
},
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": true
|
||||
},
|
||||
"min_length": {
|
||||
"name": "Minimum Length",
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "8"
|
||||
},
|
||||
"upper": {
|
||||
"name": "Minimum uppercase characters",
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "1"
|
||||
},
|
||||
"lower": {
|
||||
"name": "Minimum lowercase characters",
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "0"
|
||||
},
|
||||
"number": {
|
||||
"name": "Minimum number count",
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "1"
|
||||
},
|
||||
"special": {
|
||||
"name": "Minimum number of special characters",
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "0"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"meta": {
|
||||
"name": "Email",
|
||||
"description": "General email settings. Ignore if not using email features."
|
||||
},
|
||||
"no_username": {
|
||||
"name": "Use email addresses as username",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Use email address from invite form as username on Jellyfin."
|
||||
},
|
||||
"use_24h": {
|
||||
"name": "Use 24h time",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "bool",
|
||||
"value": true
|
||||
},
|
||||
"date_format": {
|
||||
"name": "Date format",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "text",
|
||||
"value": "%d/%m/%y",
|
||||
"description": "Date format used in emails. Follows datetime.strftime format."
|
||||
},
|
||||
"message": {
|
||||
"name": "Help message",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "text",
|
||||
"value": "Need help? contact me.",
|
||||
"description": "Message displayed at bottom of emails."
|
||||
},
|
||||
"method": {
|
||||
"name": "Email method",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "select",
|
||||
"options": [
|
||||
"smtp",
|
||||
"mailgun"
|
||||
],
|
||||
"value": "smtp",
|
||||
"description": "Method of sending email to use."
|
||||
},
|
||||
"address": {
|
||||
"name": "Sent from (address)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "email",
|
||||
"value": "jellyfin@jellyf.in",
|
||||
"description": "Address to send emails from"
|
||||
},
|
||||
"from": {
|
||||
"name": "Sent from (name)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "text",
|
||||
"value": "Jellyfin",
|
||||
"description": "The name of the sender"
|
||||
}
|
||||
},
|
||||
"password_resets": {
|
||||
"meta": {
|
||||
"name": "Password Resets",
|
||||
"description": "Settings for the password reset handler."
|
||||
},
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Enable to store provided email addresses, monitor Jellyfin directory for pw-resets, and send reset pins"
|
||||
},
|
||||
"watch_directory": {
|
||||
"name": "Jellyfin directory",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "/path/to/jellyfin",
|
||||
"description": "Path to the folder Jellyfin puts password-reset files."
|
||||
},
|
||||
"email_html": {
|
||||
"name": "Custom email (HTML)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email html"
|
||||
},
|
||||
"email_text": {
|
||||
"name": "Custom email (plaintext)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email in plain text"
|
||||
},
|
||||
"subject": {
|
||||
"name": "Email subject",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "Password Reset - Jellyfin",
|
||||
"description": "Subject of password reset emails."
|
||||
}
|
||||
},
|
||||
"invite_emails": {
|
||||
"meta": {
|
||||
"name": "Invite emails",
|
||||
"description": "Settings for sending invites directly to users."
|
||||
},
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": true
|
||||
},
|
||||
"email_html": {
|
||||
"name": "Custom email (HTML)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email HTML"
|
||||
},
|
||||
"email_text": {
|
||||
"name": "Custom email (plaintext)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email in plain text"
|
||||
},
|
||||
"subject": {
|
||||
"name": "Email subject",
|
||||
"required": true,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "Invite - Jellyfin",
|
||||
"description": "Subject of invite emails."
|
||||
},
|
||||
"url_base": {
|
||||
"name": "URL Base",
|
||||
"required": true,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "http://accounts.jellyf.in:8056/invite",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"mailgun": {
|
||||
"meta": {
|
||||
"name": "Mailgun (Email)",
|
||||
"description": "Mailgun API connection settings"
|
||||
},
|
||||
"api_url": {
|
||||
"name": "API URL",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "https://api.mailgun.net..."
|
||||
},
|
||||
"api_key": {
|
||||
"name": "API Key",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "your api key"
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"meta": {
|
||||
"name": "SMTP (Email)",
|
||||
"description": "SMTP Server connection settings."
|
||||
},
|
||||
"encryption": {
|
||||
"name": "Encryption Method",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "select",
|
||||
"options": [
|
||||
"ssl_tls",
|
||||
"starttls"
|
||||
],
|
||||
"value": "starttls",
|
||||
"description": "Your email provider should provide different ports for each encryption method. Generally 465 for ssl_tls, 587 for starttls."
|
||||
},
|
||||
"server": {
|
||||
"name": "Server address",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "smtp.jellyf.in",
|
||||
"description": "SMTP Server address."
|
||||
},
|
||||
"port": {
|
||||
"name": "Port",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "number",
|
||||
"value": 465
|
||||
},
|
||||
"password": {
|
||||
"name": "Password",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "password",
|
||||
"value": "smtp password"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"meta": {
|
||||
"name": "File Storage",
|
||||
"description": "Optional settings for changing storage locations."
|
||||
},
|
||||
"invites": {
|
||||
"name": "Invite Storage",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored invites (json)."
|
||||
},
|
||||
"emails": {
|
||||
"name": "Email Addresses",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored email addresses (json)."
|
||||
},
|
||||
"user_template": {
|
||||
"name": "User Template",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored user policy template (json)."
|
||||
},
|
||||
"user_configuration": {
|
||||
"name": "userConfiguration",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored user configuration template (used for setting homescreen layout) (json)"
|
||||
},
|
||||
"user_displayprefs": {
|
||||
"name": "displayPreferences",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored displayPreferences template (also used for homescreen layout) (json)"
|
||||
},
|
||||
"custom_css": {
|
||||
"name": "Custom CSS",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of custom bootstrap CSS."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
[jellyfin]
|
||||
; It is reccommended to create a limited admin account for this program.
|
||||
username = username
|
||||
password = password
|
||||
; Jellyfin server address. Can be public, or local for security purposes.
|
||||
server = http://jellyfin.local:8096
|
||||
; Publicly accessible Jellyfin address, used on invite form.
|
||||
; Leave blank to use the same address as above.
|
||||
public_server = https://jellyf.in:443
|
||||
client = jf-accounts
|
||||
version = 0.1
|
||||
device = jf-accounts
|
||||
device_id = jf-accounts-0.1
|
||||
|
||||
[ui]
|
||||
; Set 0.0.0.0 to run localhost
|
||||
host = 0.0.0.0
|
||||
port = 8056
|
||||
; Enable this to use Jellyfin users instead of the below username and pw.
|
||||
jellyfin_login = true
|
||||
; Allows only admin users on Jellyfin to access admin page.
|
||||
admin_only = true
|
||||
; Username to use on admin page... (leave blank if using jellyfin_login)
|
||||
username = your username
|
||||
; ..and its corresponding password (leave blank if using jellyfin_login)
|
||||
password = your password
|
||||
|
||||
debug = false
|
||||
|
||||
; Displayed at the bottom of all pages except admin
|
||||
contact_message = Need help? contact me.
|
||||
; Displayed at top of form page.
|
||||
help_message = Enter your details to create an account.
|
||||
; Displayed when an account is created.
|
||||
success_message = Your account has been created. Click below to continue to Jellyfin.
|
||||
|
||||
[password_validation]
|
||||
; Enables password validation.
|
||||
enabled = true
|
||||
; Min. password length
|
||||
min_length = 8
|
||||
; Min. number of uppercase characters
|
||||
upper = 1
|
||||
; Min. number of lowercase characters
|
||||
lower = 0
|
||||
; Min. number of numbers
|
||||
number = 1
|
||||
; Min. number of special characters
|
||||
special = 0
|
||||
|
||||
[email]
|
||||
; Leave this whole section if you aren't using any email-related features.
|
||||
use_24h = true
|
||||
; Date format follows datetime's strftime.
|
||||
date_format = %d/%m/%y
|
||||
; Displayed at bottom of emails
|
||||
message = Need help? contact me.
|
||||
; Mail methods: mailgun, smtp
|
||||
method = smtp
|
||||
; Address to send from
|
||||
address = jellyfin@jellyf.in
|
||||
; The name of the sender
|
||||
from = Jellyfin
|
||||
|
||||
[password_resets]
|
||||
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
|
||||
enabled = true
|
||||
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
|
||||
watch_directory = /path/to/jellyfin
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_html =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_text =
|
||||
; Subject of emails
|
||||
subject = Password Reset - Jellyfin
|
||||
|
||||
[invite_emails]
|
||||
; If enabled, allows one to send an invite directly to an email address.
|
||||
enabled = true
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_html =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_text =
|
||||
subject = Invite - Jellyfin
|
||||
; Base url for jf-accounts. This necessary because most will use a reverse proxy, so the program has no other way of knowing what URL to send.
|
||||
url_base = http://accounts.jellyf.in:8056/invite
|
||||
|
||||
[mailgun]
|
||||
|
||||
api_url = https://api.mailgun.net...
|
||||
api_key = your api key
|
||||
|
||||
[smtp]
|
||||
; Choose between ssl_tls and starttls. Your provider should tell you which to use, but generally SSL/TLS is 465, STARTTLS 587
|
||||
encryption = starttls
|
||||
server = smtp.jellyf.in
|
||||
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
|
||||
port = 465
|
||||
password = smtp password
|
||||
|
||||
[files]
|
||||
; When the below paths are left blank, files are stored in ~/.jf-accounts/.
|
||||
|
||||
; Path to store valid invites.
|
||||
invites =
|
||||
; Path to store emails addresses in JSON
|
||||
emails =
|
||||
; Path to the user policy template. Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_template =
|
||||
; Path to the user configuration template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_configuration =
|
||||
; Path to the user display preferences template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_displayprefs =
|
||||
; Path to custom bootstrap.css
|
||||
custom_css =
|
||||
|
||||
@@ -241,9 +241,6 @@ $("form#loginForm").submit(function() {
|
||||
});
|
||||
return false;
|
||||
});
|
||||
document.getElementById('openSettings').onclick = function () {
|
||||
$('#settingsMenu').modal('show');
|
||||
}
|
||||
document.getElementById('openDefaultsWizard').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
@@ -294,7 +291,8 @@ document.getElementById('openDefaultsWizard').onclick = function () {
|
||||
} else if (submitButton.classList.contains('btn-danger')) {
|
||||
submitButton.classList.remove('btn-danger');
|
||||
submitButton.classList.add('btn-primary');
|
||||
}
|
||||
};
|
||||
$('#settingsMenu').modal('hide');
|
||||
$('#userDefaults').modal('show');
|
||||
}
|
||||
}
|
||||
@@ -336,6 +334,7 @@ document.getElementById('storeDefaults').onclick = function () {
|
||||
},
|
||||
error: function() {
|
||||
button.textContent = 'Failed';
|
||||
config_base_path = local_dir / "config-base.json"
|
||||
button.classList.remove('btn-primary');
|
||||
button.classList.add('btn-danger');
|
||||
setTimeout(function(){
|
||||
@@ -442,6 +441,7 @@ document.getElementById('openUsers').onclick = function () {
|
||||
var button = document.getElementById('openUsers');
|
||||
button.disabled = false;
|
||||
button.innerHTML = 'Users <i class="fa fa-user"></i>';
|
||||
$('#settingsMenu').modal('hide');
|
||||
$('#users').modal('show');
|
||||
};
|
||||
}
|
||||
@@ -449,3 +449,217 @@ document.getElementById('openUsers').onclick = function () {
|
||||
};
|
||||
generateInvites(empty = true);
|
||||
$("#login").modal('show');
|
||||
|
||||
var config = {};
|
||||
var modifiedConfig = {};
|
||||
|
||||
document.getElementById('openSettings').onclick = function () {
|
||||
restart_setting_changed = false;
|
||||
$.ajax('getConfig', {
|
||||
type : 'GET',
|
||||
dataType : 'json',
|
||||
contentType : 'json',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
complete : function(data) {
|
||||
if (data['status'] == 200) {
|
||||
var settingsList = document.getElementById('settingsList');
|
||||
settingsList.textContent = '';
|
||||
config = data['responseJSON'];
|
||||
for (var section of Object.keys(config)) {
|
||||
var sectionCollapse = document.createElement('div');
|
||||
sectionCollapse.classList.add('collapse');
|
||||
sectionCollapse.id = section;
|
||||
|
||||
var sectionTitle = config[section]['meta']['name'];
|
||||
var sectionDescription = config[section]['meta']['description'];
|
||||
var entryListID = section + '_entryList';
|
||||
var sectionFooter = section + '_footer';
|
||||
|
||||
var innerCollapse = `
|
||||
<div class="card card-body">
|
||||
<small class="text-muted">${sectionDescription}</small>
|
||||
<div class="${entryListID}">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
sectionCollapse.innerHTML = innerCollapse;
|
||||
|
||||
for (var entry of Object.keys(config[section])) {
|
||||
if (entry != 'meta') {
|
||||
var entryName = config[section][entry]['name'];
|
||||
var required = false;
|
||||
if (config[section][entry]['required']) {
|
||||
entryName += ' <sup class="text-danger">*</sup>';
|
||||
required = true;
|
||||
};
|
||||
if (config[section][entry]['requires_restart']) {
|
||||
entryName += ' <sup class="text-danger">R</sup>';
|
||||
};
|
||||
if (config[section][entry].hasOwnProperty('description')) {
|
||||
var tooltip = `
|
||||
<a class="text-muted" href="#" data-toggle="tooltip" data-placement="right" title="${config[section][entry]['description']}"><i class="fa fa-question-circle-o"></i></a>
|
||||
`;
|
||||
entryName += ' ';
|
||||
entryName += tooltip;
|
||||
};
|
||||
var entryValue = config[section][entry]['value'];
|
||||
var entryType = config[section][entry]['type'];
|
||||
var entryGroup = document.createElement('div');
|
||||
if (entryType == 'bool') {
|
||||
entryGroup.classList.add('form-check');
|
||||
if (entryValue.toString() == 'true') {
|
||||
var checked = true;
|
||||
} else {
|
||||
var checked = false;
|
||||
};
|
||||
entryGroup.innerHTML = `
|
||||
<input class="form-check-input" type="checkbox" value="" id="${section}_${entry}">
|
||||
<label class="form-check-label" for="${section}_${entry}">${entryName}</label>
|
||||
`;
|
||||
entryGroup.getElementsByClassName('form-check-input')[0].required = required;
|
||||
entryGroup.getElementsByClassName('form-check-input')[0].checked = checked;
|
||||
entryGroup.getElementsByClassName('form-check-input')[0].onclick = function() {
|
||||
var state = this.checked;
|
||||
for (var sect of Object.keys(config)) {
|
||||
for (var ent of Object.keys(config[sect])) {
|
||||
if ((sect + '_' + config[sect][ent]['depends_true']) == this.id) {
|
||||
document.getElementById(sect + '_' + ent).disabled = !state;
|
||||
} else if ((sect + '_' + config[sect][ent]['depends_false']) == this.id) {
|
||||
document.getElementById(sect + '_' + ent).disabled = state;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} else if ((entryType == 'text') || (entryType == 'email') || (entryType == 'password') || (entryType == 'number')) {
|
||||
entryGroup.classList.add('form-group');
|
||||
entryGroup.innerHTML = `
|
||||
<label for="${section}_${entry}">${entryName}</label>
|
||||
<input type="${entryType}" class="form-control" id="${section}_${entry}" aria-describedby="${entry}" value="${entryValue}">
|
||||
`;
|
||||
entryGroup.getElementsByClassName('form-control')[0].required = required;
|
||||
} else if (entryType == 'select') {
|
||||
entryGroup.classList.add('form-group');
|
||||
var entryOptions = config[section][entry]['options'];
|
||||
var innerGroup = `
|
||||
<label for="${section}_${entry}">${entryName}</label>
|
||||
<select class="form-control" id="${section}_${entry}">
|
||||
`;
|
||||
for (var i = 0; i < entryOptions.length; i++) {
|
||||
if (entryOptions[i] == entryValue) {
|
||||
var selected = 'selected';
|
||||
} else {
|
||||
var selected = '';
|
||||
}
|
||||
innerGroup += `
|
||||
<option value="${entryOptions[i]}" ${selected}>${entryOptions[i]}</option>
|
||||
`;
|
||||
};
|
||||
innerGroup += '</select>';
|
||||
entryGroup.innerHTML = innerGroup;
|
||||
entryGroup.getElementsByClassName('form-control')[0].required = required;
|
||||
|
||||
};
|
||||
sectionCollapse.getElementsByClassName(entryListID)[0].appendChild(entryGroup);
|
||||
};
|
||||
};
|
||||
var sectionButton = document.createElement('button');
|
||||
sectionButton.setAttribute('type', 'button');
|
||||
sectionButton.classList.add('list-group-item', 'list-group-item-action');
|
||||
sectionButton.appendChild(document.createTextNode(sectionTitle));
|
||||
sectionButton.id = section + '_button';
|
||||
sectionButton.setAttribute('data-toggle', 'collapse');
|
||||
sectionButton.setAttribute('data-target', '#' + section);
|
||||
settingsList.appendChild(sectionButton);
|
||||
settingsList.appendChild(sectionCollapse);
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
$('#settingsMenu').modal('show');
|
||||
};
|
||||
|
||||
$('#settingsMenu').on('shown.bs.modal', function() {
|
||||
$("a[data-toggle='tooltip']").each(function (i, obj) {
|
||||
$(obj).tooltip();
|
||||
});
|
||||
});
|
||||
|
||||
function sendConfig(modalId) {
|
||||
var modal = document.getElementById(modalId);
|
||||
var send = JSON.stringify(modifiedConfig);
|
||||
$.ajax('/modifyConfig', {
|
||||
data : send,
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() {
|
||||
$('#' + modalId).modal('hide');
|
||||
if (modalId != 'settingsMenu') {
|
||||
$('#settingsMenu').modal('hide');
|
||||
};
|
||||
},
|
||||
fail: function(xhr, textStatus, errorThrown) {
|
||||
var footer = modal.getElementsByClassName('modal-dialog')[0].getElementsByClassName('modal-content')[0].getElementsByClassName('modal-footer')[0];
|
||||
var alert = document.createElement('div');
|
||||
alert.classList.add('alert', 'alert-danger');
|
||||
alert.setAttribute('role', 'alert');
|
||||
alert.appendChild(document.createTextNode('Error: ' + errorThrown));
|
||||
footer.appendChild(alert);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('settingsSave').onclick = function() {
|
||||
modifiedConfig = {};
|
||||
var restart_setting_changed = false;
|
||||
var settings_changed = false;
|
||||
|
||||
for (var section of Object.keys(config)) {
|
||||
for (var entry of Object.keys(config[section])) {
|
||||
if (entry != 'meta') {
|
||||
var entryID = section + '_' + entry;
|
||||
var el = document.getElementById(entryID);
|
||||
if (el.type == 'checkbox') {
|
||||
var value = el.checked.toString();
|
||||
} else {
|
||||
var value = el.value.toString();
|
||||
};
|
||||
if (value != config[section][entry]['value'].toString()) {
|
||||
if (!modifiedConfig.hasOwnProperty(section)) {
|
||||
modifiedConfig[section] = {};
|
||||
};
|
||||
modifiedConfig[section][entry] = value;
|
||||
settings_changed = true;
|
||||
if (config[section][entry]['requires_restart']) {
|
||||
restart_setting_changed = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
// if (restart_setting_changed) {
|
||||
if (restart_setting_changed) {
|
||||
document.getElementById('applyRestarts').onclick = function(){sendConfig('restartModal');};
|
||||
$('#settingsMenu').modal('hide');
|
||||
$('#restartModal').modal({
|
||||
backdrop: 'static',
|
||||
show: true
|
||||
});
|
||||
} else if (settings_changed) {
|
||||
sendConfig('settingsMenu');
|
||||
} else {
|
||||
$('#settingsMenu').modal('hide');
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<style>
|
||||
.pageContainer {
|
||||
margin: 20%;
|
||||
margin: 5% 20% 5% 20%;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.pageContainer {
|
||||
@@ -93,20 +93,20 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<button type="button" class="btn btn-secondary" id="openUsers">
|
||||
Users <i class="fa fa-user"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<button type="button" class="btn btn-secondary" id="openDefaultsWizard">
|
||||
New account defaults
|
||||
</button>
|
||||
</li>
|
||||
<p>Note: <sup class="text-danger">*</sup> Indicates required field, <sup class="text-danger">R</sup> Indicates changes require a restart.</p>
|
||||
<button type="button" class="list-group-item list-group-item-action" id="openUsers">
|
||||
Users <i class="fa fa-user"></i>
|
||||
</button>
|
||||
<button type="button" class="list-group-item list-group-item-action" id="openDefaultsWizard">
|
||||
New account defaults
|
||||
</button>
|
||||
</ul>
|
||||
<div class="list-group list-group-flush" id="settingsList">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" id="settingsFooter">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="settingsSave">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,6 +153,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="restartModal" tabindex="-1" role="dialog" aria-labelledby"Restart Warning" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Warning</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>A restart is needed to apply some settings. This must be done manually. Apply now?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="applyRestarts" data-dismiss="alert">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pageContainer">
|
||||
<h1>
|
||||
Accounts admin
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
.pageContainer {
|
||||
margin: 20%;
|
||||
margin: 5% 20% 5% 20%;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.pageContainer {
|
||||
@@ -68,12 +68,14 @@
|
||||
<form action="#" method="POST">
|
||||
<div class="form-group">
|
||||
<label for="inputEmail">Email</label>
|
||||
<input type="email" class="form-control" id="inputEmail" name="email" placeholder="Email" value="{{ email }}" required>
|
||||
<input type="email" class="form-control" id="{% if username %}inputEmail{% else %}inputUsername{% endif %}" name="{% if username %}email{% else %}username{% endif %}" placeholder="Email" value="{{ email }}" required>
|
||||
</div>
|
||||
{% if username %}
|
||||
<div class="form-group">
|
||||
<label for="inputUsername">Username</label>
|
||||
<input type="username" class="form-control" id="inputUsername" name="username" placeholder="Username" required>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="inputPassword">Password</label>
|
||||
<input type="password" class="form-control" id="inputPassword" name="password" placeholder="Password" required>
|
||||
@@ -133,6 +135,9 @@
|
||||
toggleSpinner();
|
||||
var send = $("form").serializeObject();
|
||||
send['code'] = code;
|
||||
{% if not username %}
|
||||
send['email'] = send['username'];
|
||||
{% endif %}
|
||||
send = JSON.stringify(send);
|
||||
$.ajax('/newUser', {
|
||||
data : send,
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import json
|
||||
import datetime
|
||||
|
||||
|
||||
class JSONFile(dict):
|
||||
"""
|
||||
Behaves like a dictionary, but automatically
|
||||
reads and writes to a JSON file (most of the time).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def readJSON(path):
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def writeJSON(path, data):
|
||||
with open(path, 'w') as f:
|
||||
with open(path, "w") as f:
|
||||
return f.write(json.dumps(data, indent=4, default=str))
|
||||
|
||||
|
||||
def __init__(self, path, data=None):
|
||||
self.path = path
|
||||
if data is None:
|
||||
@@ -30,14 +36,14 @@ class JSONFile(dict):
|
||||
def __setitem__(self, key, value):
|
||||
data = self.readJSON(self.path)
|
||||
data[key] = value
|
||||
self.writeJSON(self.path, data)
|
||||
self.writeJSON(self.path, data)
|
||||
super(JSONFile, self).__init__(data)
|
||||
|
||||
def __delitem__(self, key):
|
||||
data = self.readJSON(self.path)
|
||||
super(JSONFile, self).__init__(data)
|
||||
del data[key]
|
||||
self.writeJSON(self.path, data)
|
||||
self.writeJSON(self.path, data)
|
||||
super(JSONFile, self).__delitem__(key)
|
||||
|
||||
def __str__(self):
|
||||
@@ -46,18 +52,15 @@ class JSONFile(dict):
|
||||
|
||||
|
||||
class JSONStorage:
|
||||
def __init__(self,
|
||||
emails,
|
||||
invites,
|
||||
user_template,
|
||||
user_displayprefs,
|
||||
user_configuration):
|
||||
def __init__(
|
||||
self, emails, invites, user_template, user_displayprefs, user_configuration
|
||||
):
|
||||
self.emails = JSONFile(path=emails)
|
||||
self.invites = JSONFile(path=invites)
|
||||
self.user_template = JSONFile(path=user_template)
|
||||
self.user_displayprefs = JSONFile(path=user_displayprefs)
|
||||
self.user_configuration = JSONFile(path=user_configuration)
|
||||
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if hasattr(self, name):
|
||||
path = self.__dict__[name].path
|
||||
|
||||
@@ -12,97 +12,109 @@ from jellyfin_accounts import config
|
||||
from jellyfin_accounts import email_log as log
|
||||
|
||||
|
||||
class Email():
|
||||
class Email:
|
||||
def __init__(self, address):
|
||||
self.address = address
|
||||
log.debug(f'{self.address}: Creating email')
|
||||
log.debug(f"{self.address}: Creating email")
|
||||
self.content = {}
|
||||
self.from_address = config['email']['address']
|
||||
self.from_name = config['email']['from']
|
||||
log.debug((
|
||||
f'{self.address}: Sending from {self.from_address} ' +
|
||||
f'({self.from_name})'))
|
||||
self.from_address = config["email"]["address"]
|
||||
self.from_name = config["email"]["from"]
|
||||
log.debug(
|
||||
(
|
||||
f"{self.address}: Sending from {self.from_address} "
|
||||
+ f"({self.from_name})"
|
||||
)
|
||||
)
|
||||
|
||||
def pretty_time(self, expiry):
|
||||
current_time = datetime.datetime.now()
|
||||
date = expiry.strftime(config['email']['date_format'])
|
||||
if config.getboolean('email', 'use_24h'):
|
||||
log.debug(f'{self.address}: Using 24h time')
|
||||
time = expiry.strftime('%H:%M')
|
||||
date = expiry.strftime(config["email"]["date_format"])
|
||||
if config.getboolean("email", "use_24h"):
|
||||
log.debug(f"{self.address}: Using 24h time")
|
||||
time = expiry.strftime("%H:%M")
|
||||
else:
|
||||
log.debug(f'{self.address}: Using 12h time')
|
||||
time = expiry.strftime('%-I:%M %p')
|
||||
log.debug(f"{self.address}: Using 12h time")
|
||||
time = expiry.strftime("%-I:%M %p")
|
||||
expiry_delta = (expiry - current_time).seconds
|
||||
expires_in = {'hours': expiry_delta//3600,
|
||||
'minutes': (expiry_delta//60) % 60}
|
||||
if expires_in['hours'] == 0:
|
||||
expires_in = {
|
||||
"hours": expiry_delta // 3600,
|
||||
"minutes": (expiry_delta // 60) % 60,
|
||||
}
|
||||
if expires_in["hours"] == 0:
|
||||
expires_in = f'{str(expires_in["minutes"])}m'
|
||||
else:
|
||||
expires_in = (f'{str(expires_in["hours"])}h ' +
|
||||
f'{str(expires_in["minutes"])}m')
|
||||
log.debug(f'{self.address}: Expires in {expires_in}')
|
||||
return {'date': date, 'time': time, 'expires_in': expires_in}
|
||||
expires_in = (
|
||||
f'{str(expires_in["hours"])}h ' + f'{str(expires_in["minutes"])}m'
|
||||
)
|
||||
log.debug(f"{self.address}: Expires in {expires_in}")
|
||||
return {"date": date, "time": time, "expires_in": expires_in}
|
||||
|
||||
def construct_invite(self, invite):
|
||||
self.subject = config['invite_emails']['subject']
|
||||
log.debug(f'{self.address}: Using subject {self.subject}')
|
||||
log.debug(f'{self.address}: Constructing email content')
|
||||
expiry = invite['expiry']
|
||||
self.subject = config["invite_emails"]["subject"]
|
||||
log.debug(f"{self.address}: Using subject {self.subject}")
|
||||
log.debug(f"{self.address}: Constructing email content")
|
||||
expiry = invite["expiry"]
|
||||
expiry.replace(tzinfo=None)
|
||||
pretty = self.pretty_time(expiry)
|
||||
email_message = config['email']['message']
|
||||
invite_link = config['invite_emails']['url_base']
|
||||
invite_link += '/' + invite['code']
|
||||
for key in ['text', 'html']:
|
||||
sp = Path(config['invite_emails']['email_' + key]) / '..'
|
||||
sp = str(sp.resolve()) + '/'
|
||||
email_message = config["email"]["message"]
|
||||
invite_link = config["invite_emails"]["url_base"]
|
||||
invite_link += "/" + invite["code"]
|
||||
for key in ["text", "html"]:
|
||||
sp = Path(config["invite_emails"]["email_" + key]) / ".."
|
||||
sp = str(sp.resolve()) + "/"
|
||||
template_loader = FileSystemLoader(searchpath=sp)
|
||||
template_env = Environment(loader=template_loader)
|
||||
fname = Path(config['invite_emails']['email_' + key]).name
|
||||
fname = Path(config["invite_emails"]["email_" + key]).name
|
||||
template = template_env.get_template(fname)
|
||||
c = template.render(expiry_date=pretty['date'],
|
||||
expiry_time=pretty['time'],
|
||||
expires_in=pretty['expires_in'],
|
||||
invite_link=invite_link,
|
||||
message=email_message)
|
||||
c = template.render(
|
||||
expiry_date=pretty["date"],
|
||||
expiry_time=pretty["time"],
|
||||
expires_in=pretty["expires_in"],
|
||||
invite_link=invite_link,
|
||||
message=email_message,
|
||||
)
|
||||
self.content[key] = c
|
||||
log.info(f'{self.address}: {key} constructed')
|
||||
log.info(f"{self.address}: {key} constructed")
|
||||
|
||||
def construct_reset(self, reset):
|
||||
self.subject = config['password_resets']['subject']
|
||||
log.debug(f'{self.address}: Using subject {self.subject}')
|
||||
log.debug(f'{self.address}: Constructing email content')
|
||||
self.subject = config["password_resets"]["subject"]
|
||||
log.debug(f"{self.address}: Using subject {self.subject}")
|
||||
log.debug(f"{self.address}: Constructing email content")
|
||||
try:
|
||||
expiry = date_parser.parse(reset['ExpirationDate'])
|
||||
expiry = date_parser.parse(reset["ExpirationDate"])
|
||||
expiry = expiry.replace(tzinfo=None)
|
||||
except:
|
||||
log.error(f"{self.address}: Couldn't parse expiry time")
|
||||
return False
|
||||
current_time = datetime.datetime.now()
|
||||
if expiry >= current_time:
|
||||
log.debug(f'{self.address}: Invite valid')
|
||||
log.debug(f"{self.address}: Invite valid")
|
||||
pretty = self.pretty_time(expiry)
|
||||
email_message = config['email']['message']
|
||||
for key in ['text', 'html']:
|
||||
sp = Path(config['password_resets']['email_' + key]) / '..'
|
||||
sp = str(sp.resolve()) + '/'
|
||||
email_message = config["email"]["message"]
|
||||
for key in ["text", "html"]:
|
||||
sp = Path(config["password_resets"]["email_" + key]) / ".."
|
||||
sp = str(sp.resolve()) + "/"
|
||||
template_loader = FileSystemLoader(searchpath=sp)
|
||||
template_env = Environment(loader=template_loader)
|
||||
fname = Path(config['password_resets']['email_' + key]).name
|
||||
fname = Path(config["password_resets"]["email_" + key]).name
|
||||
template = template_env.get_template(fname)
|
||||
c = template.render(username=reset['UserName'],
|
||||
expiry_date=pretty['date'],
|
||||
expiry_time=pretty['time'],
|
||||
expires_in=pretty['expires_in'],
|
||||
pin=reset['Pin'],
|
||||
message=email_message)
|
||||
c = template.render(
|
||||
username=reset["UserName"],
|
||||
expiry_date=pretty["date"],
|
||||
expiry_time=pretty["time"],
|
||||
expires_in=pretty["expires_in"],
|
||||
pin=reset["Pin"],
|
||||
message=email_message,
|
||||
)
|
||||
self.content[key] = c
|
||||
log.info(f'{self.address}: {key} constructed')
|
||||
log.info(f"{self.address}: {key} constructed")
|
||||
return True
|
||||
else:
|
||||
err = ((f"{self.address}: " +
|
||||
"Reset has reportedly already expired. " +
|
||||
"Ensure timezones are correctly configured."))
|
||||
err = (
|
||||
f"{self.address}: "
|
||||
+ "Reset has reportedly already expired. "
|
||||
+ "Ensure timezones are correctly configured."
|
||||
)
|
||||
log.error(err)
|
||||
return False
|
||||
|
||||
@@ -110,71 +122,74 @@ class Email():
|
||||
class Mailgun(Email):
|
||||
def __init__(self, address):
|
||||
super().__init__(address)
|
||||
self.api_url = config['mailgun']['api_url']
|
||||
self.api_key = config['mailgun']['api_key']
|
||||
self.from_mg = f'{self.from_name} <{self.from_address}>'
|
||||
self.api_url = config["mailgun"]["api_url"]
|
||||
self.api_key = config["mailgun"]["api_key"]
|
||||
self.from_mg = f"{self.from_name} <{self.from_address}>"
|
||||
|
||||
def send(self):
|
||||
response = requests.post(self.api_url,
|
||||
auth=("api", self.api_key),
|
||||
data={"from": self.from_mg,
|
||||
"to": [self.address],
|
||||
"subject": self.subject,
|
||||
"text": self.content['text'],
|
||||
"html": self.content['html']})
|
||||
response = requests.post(
|
||||
self.api_url,
|
||||
auth=("api", self.api_key),
|
||||
data={
|
||||
"from": self.from_mg,
|
||||
"to": [self.address],
|
||||
"subject": self.subject,
|
||||
"text": self.content["text"],
|
||||
"html": self.content["html"],
|
||||
},
|
||||
)
|
||||
if response.ok:
|
||||
log.info(f'{self.address}: Sent via mailgun.')
|
||||
log.info(f"{self.address}: Sent via mailgun.")
|
||||
return True
|
||||
log.debug(f'{self.address}: Mailgun: {response.status_code}')
|
||||
log.debug(f"{self.address}: Mailgun: {response.status_code}")
|
||||
return response
|
||||
|
||||
|
||||
class Smtp(Email):
|
||||
def __init__(self, address):
|
||||
super().__init__(address)
|
||||
self.server = config['smtp']['server']
|
||||
self.password = config['smtp']['password']
|
||||
self.server = config["smtp"]["server"]
|
||||
self.password = config["smtp"]["password"]
|
||||
try:
|
||||
self.port = int(config['smtp']['port'])
|
||||
self.port = int(config["smtp"]["port"])
|
||||
except ValueError:
|
||||
self.port = 465
|
||||
log.debug(f'{self.address}: Defaulting to port {self.port}')
|
||||
log.debug(f"{self.address}: Defaulting to port {self.port}")
|
||||
|
||||
def send(self):
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = self.subject
|
||||
message["From"] = self.from_address
|
||||
message["To"] = self.address
|
||||
text = MIMEText(self.content['text'], 'plain')
|
||||
html = MIMEText(self.content['html'], 'html')
|
||||
text = MIMEText(self.content["text"], "plain")
|
||||
html = MIMEText(self.content["html"], "html")
|
||||
message.attach(text)
|
||||
message.attach(html)
|
||||
try:
|
||||
if config['smtp']['encryption'] == 'ssl_tls':
|
||||
if config["smtp"]["encryption"] == "ssl_tls":
|
||||
self.context = ssl.create_default_context()
|
||||
with smtplib.SMTP_SSL(self.server,
|
||||
self.port,
|
||||
context=self.context) as server:
|
||||
with smtplib.SMTP_SSL(
|
||||
self.server, self.port, context=self.context
|
||||
) as server:
|
||||
server.ehlo()
|
||||
server.login(self.from_address, self.password)
|
||||
server.sendmail(self.from_address,
|
||||
self.address,
|
||||
message.as_string())
|
||||
log.info(f'{self.address}: Sent via smtp (ssl/tls)')
|
||||
server.sendmail(
|
||||
self.from_address, self.address, message.as_string()
|
||||
)
|
||||
log.info(f"{self.address}: Sent via smtp (ssl/tls)")
|
||||
return True
|
||||
elif config['smtp']['encryption'] == 'starttls':
|
||||
with smtplib.SMTP(self.server,
|
||||
self.port) as server:
|
||||
elif config["smtp"]["encryption"] == "starttls":
|
||||
with smtplib.SMTP(self.server, self.port) as server:
|
||||
server.ehlo()
|
||||
server.starttls()
|
||||
server.login(self.from_address, self.password)
|
||||
server.sendmail(self.from_address,
|
||||
self.address,
|
||||
message.as_string())
|
||||
log.info(f'{self.address}: Sent via smtp (starttls)')
|
||||
server.sendmail(
|
||||
self.from_address, self.address, message.as_string()
|
||||
)
|
||||
log.info(f"{self.address}: Sent via smtp (starttls)")
|
||||
return True
|
||||
except Exception as e:
|
||||
err = f'{self.address}: Failed to send via smtp: '
|
||||
err = f"{self.address}: Failed to send via smtp: "
|
||||
err += type(e).__name__
|
||||
log.error(err)
|
||||
try:
|
||||
|
||||
35
jellyfin_accounts/generate_ini.py
Normal file
35
jellyfin_accounts/generate_ini.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import configparser
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def generate_ini(base_file, ini_file, version):
|
||||
"""
|
||||
Generates .ini file from config-base file.
|
||||
"""
|
||||
with open(Path(base_file), "r") as f:
|
||||
config_base = json.load(f)
|
||||
|
||||
ini = configparser.RawConfigParser(allow_no_value=True)
|
||||
|
||||
for section in config_base:
|
||||
ini.add_section(section)
|
||||
for entry in config_base[section]:
|
||||
if "description" in config_base[section][entry]:
|
||||
ini.set(section, "; " + config_base[section][entry]["description"])
|
||||
if entry != "meta":
|
||||
value = config_base[section][entry]["value"]
|
||||
if isinstance(value, bool):
|
||||
value = str(value).lower()
|
||||
else:
|
||||
value = str(value)
|
||||
ini.set(section, entry, value)
|
||||
|
||||
ini["jellyfin"]["version"] = version
|
||||
ini["jellyfin"]["device_id"] = ini["jellyfin"]["device_id"].replace(
|
||||
"{version}", version
|
||||
)
|
||||
|
||||
with open(Path(ini_file), "w") as config_file:
|
||||
ini.write(config_file)
|
||||
return True
|
||||
@@ -2,35 +2,48 @@
|
||||
import requests
|
||||
import time
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Jellyfin:
|
||||
"""
|
||||
Basic Jellyfin API client, providing account related function only.
|
||||
"""
|
||||
|
||||
class UserExistsError(Error):
|
||||
"""
|
||||
Thrown if a user already exists with the same name
|
||||
when creating an account.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class UserNotFoundError(Error):
|
||||
"""Thrown if account with specified user ID/name does not exist."""
|
||||
|
||||
pass
|
||||
|
||||
class AuthenticationError(Error):
|
||||
"""Thrown if authentication with Jellyfin fails."""
|
||||
|
||||
pass
|
||||
|
||||
class AuthenticationRequiredError(Error):
|
||||
"""
|
||||
Thrown if privileged action is attempted without authentication.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class UnknownError(Error):
|
||||
"""
|
||||
Thrown if i've been too lazy to figure out an error's meaning.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def __init__(self, server, client, version, device, deviceId):
|
||||
"""
|
||||
Initializes the Jellyfin object. All parameters except server
|
||||
@@ -58,40 +71,46 @@ class Jellyfin:
|
||||
self.auth += f"DeviceId={self.deviceId}, "
|
||||
self.auth += f"Version={self.version}"
|
||||
self.header = {
|
||||
"Accept": "application/json",
|
||||
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Application": f"{self.client}/{self.version}",
|
||||
"Accept-Charset": "UTF-8,*",
|
||||
"Accept-encoding": "gzip",
|
||||
"User-Agent": self.useragent,
|
||||
"X-Emby-Authorization": self.auth
|
||||
"Accept": "application/json",
|
||||
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Application": f"{self.client}/{self.version}",
|
||||
"Accept-Charset": "UTF-8,*",
|
||||
"Accept-encoding": "gzip",
|
||||
"User-Agent": self.useragent,
|
||||
"X-Emby-Authorization": self.auth,
|
||||
}
|
||||
def getUsers(self, username="all", id="all", public=True):
|
||||
try:
|
||||
self.info = requests.get(self.server + "/System/Info/Public").json()
|
||||
except:
|
||||
pass
|
||||
|
||||
def getUsers(self, username: str = "all", userId: str = "all", public: bool = True):
|
||||
"""
|
||||
Returns details on user(s), such as ID, Name, Policy.
|
||||
|
||||
:param username: (optional) Username to get info about.
|
||||
Leave blank to get all users.
|
||||
:param id: (optional) User ID to get info about.
|
||||
:param userId: (optional) User ID to get info about.
|
||||
Leave blank to get all users.
|
||||
:param public: True = Get publicly visible users only (no auth required),
|
||||
False = Get all users (auth required).
|
||||
"""
|
||||
if public is True:
|
||||
if (time.time() - self.userCachePublicAge) >= self.timeout:
|
||||
response = requests.get(self.server+"/emby/Users/Public").json()
|
||||
response = requests.get(self.server + "/emby/Users/Public").json()
|
||||
self.userCachePublic = response
|
||||
self.userCachePublicAge = time.time()
|
||||
else:
|
||||
response = self.userCachePublic
|
||||
elif (public is False and
|
||||
hasattr(self, 'username') and
|
||||
hasattr(self, 'password')):
|
||||
elif (
|
||||
public is False and hasattr(self, "username") and hasattr(self, "password")
|
||||
):
|
||||
if (time.time() - self.userCacheAge) >= self.timeout:
|
||||
response = requests.get(self.server+"/emby/Users",
|
||||
headers=self.header,
|
||||
params={'Username': self.username,
|
||||
'Pw': self.password})
|
||||
response = requests.get(
|
||||
self.server + "/emby/Users",
|
||||
headers=self.header,
|
||||
params={"Username": self.username, "Pw": self.password},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
response = response.json()
|
||||
self.userCache = response
|
||||
@@ -99,19 +118,19 @@ class Jellyfin:
|
||||
else:
|
||||
try:
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.getUsers(username, id, public)
|
||||
return self.getUsers(username, userId, public)
|
||||
except self.AuthenticationError:
|
||||
raise self.AuthenticationRequiredError
|
||||
else:
|
||||
response = self.userCache
|
||||
else:
|
||||
raise self.AuthenticationRequiredError
|
||||
if username == "all" and id == "all":
|
||||
if username == "all" and userId == "all":
|
||||
return response
|
||||
elif id == "all":
|
||||
elif userId == "all":
|
||||
match = False
|
||||
for user in response:
|
||||
if user['Name'] == username:
|
||||
if user["Name"] == username:
|
||||
match = True
|
||||
return user
|
||||
if not match:
|
||||
@@ -119,12 +138,13 @@ class Jellyfin:
|
||||
else:
|
||||
match = False
|
||||
for user in response:
|
||||
if user['Id'] == id:
|
||||
if user["Id"] == userId:
|
||||
match = True
|
||||
return user
|
||||
if not match:
|
||||
raise self.UserNotFoundError
|
||||
def authenticate(self, username, password):
|
||||
|
||||
def authenticate(self, username: str, password: str):
|
||||
"""
|
||||
Authenticates by name with Jellyfin.
|
||||
|
||||
@@ -133,125 +153,154 @@ class Jellyfin:
|
||||
"""
|
||||
self.username = username
|
||||
self.password = password
|
||||
response = requests.post(self.server+"/emby/Users/AuthenticateByName",
|
||||
headers=self.header,
|
||||
params={'Username': self.username,
|
||||
'Pw': self.password})
|
||||
response = requests.post(
|
||||
self.server + "/emby/Users/AuthenticateByName",
|
||||
headers=self.header,
|
||||
params={"Username": self.username, "Pw": self.password},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
json = response.json()
|
||||
self.userId = json['User']['Id']
|
||||
self.accessToken = json['AccessToken']
|
||||
self.userId = json["User"]["Id"]
|
||||
self.accessToken = json["AccessToken"]
|
||||
self.auth = "MediaBrowser "
|
||||
self.auth += f"Client={self.client}, "
|
||||
self.auth += f"Device={self.device}, "
|
||||
self.auth += f"DeviceId={self.deviceId}, "
|
||||
self.auth += f"Version={self.version}"
|
||||
self.auth += f", Token={self.accessToken}"
|
||||
self.header['X-Emby-Authorization'] = self.auth
|
||||
self.header["X-Emby-Authorization"] = self.auth
|
||||
self.info = requests.get(
|
||||
self.server + "/System/Info", headers=self.header
|
||||
).json()
|
||||
return True
|
||||
else:
|
||||
raise self.AuthenticationError
|
||||
def setPolicy(self, userId, policy):
|
||||
|
||||
def setPolicy(self, userId: str, policy: dict):
|
||||
"""
|
||||
Sets a user's policy (Admin rights, Library Access, etc.) by user ID.
|
||||
|
||||
:param userId: ID of the user to modify.
|
||||
:param policy: User policy in dictionary form.
|
||||
"""
|
||||
return requests.post(self.server+"/Users/"+userId+"/Policy",
|
||||
headers=self.header,
|
||||
params=policy)
|
||||
def newUser(self, username, password):
|
||||
return requests.post(
|
||||
self.server + "/Users/" + userId + "/Policy",
|
||||
headers=self.header,
|
||||
params=policy,
|
||||
)
|
||||
|
||||
def newUser(self, username: str, password: str):
|
||||
for user in self.getUsers():
|
||||
if user['Name'] == username:
|
||||
if user["Name"] == username:
|
||||
raise self.UserExistsError
|
||||
response = requests.post(self.server+"/emby/Users/New",
|
||||
headers=self.header,
|
||||
params={'Name': username,
|
||||
'Password': password})
|
||||
response = requests.post(
|
||||
self.server + "/emby/Users/New",
|
||||
headers=self.header,
|
||||
params={"Name": username, "Password": password},
|
||||
)
|
||||
if response.status_code == 401:
|
||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
||||
if hasattr(self, "username") and hasattr(self, "password"):
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.newUser(username, password)
|
||||
else:
|
||||
raise self.AuthenticationRequiredError
|
||||
return response
|
||||
def getViewOrder(self, userId, public=True):
|
||||
|
||||
def getViewOrder(self, userId: str, public: bool = True):
|
||||
if not public:
|
||||
param = '?IncludeHidden=true'
|
||||
param = "?IncludeHidden=true"
|
||||
else:
|
||||
param = ''
|
||||
views = requests.get(self.server+"/Users/"+userId+"/Views"+param,
|
||||
headers=self.header).json()['Items']
|
||||
param = ""
|
||||
views = requests.get(
|
||||
self.server + "/Users/" + userId + "/Views" + param, headers=self.header
|
||||
).json()["Items"]
|
||||
orderedViews = []
|
||||
for library in views:
|
||||
orderedViews.append(library['Id'])
|
||||
orderedViews.append(library["Id"])
|
||||
return orderedViews
|
||||
def setConfiguration(self, userId, configuration):
|
||||
|
||||
def setConfiguration(self, userId: str, configuration: dict):
|
||||
"""
|
||||
Sets a user's configuration (Settings the user can change themselves).
|
||||
:param userId: ID of the user to modify.
|
||||
:param configuration: Configuration to write in dictionary form.
|
||||
"""
|
||||
resp = requests.post(self.server+"/Users/"+userId+"/Configuration",
|
||||
headers=self.header,
|
||||
params=configuration)
|
||||
if (resp.status_code == 200 or
|
||||
resp.status_code == 204):
|
||||
resp = requests.post(
|
||||
self.server + "/Users/" + userId + "/Configuration",
|
||||
headers=self.header,
|
||||
params=configuration,
|
||||
)
|
||||
if resp.status_code == 200 or resp.status_code == 204:
|
||||
return True
|
||||
elif resp.status_code == 401:
|
||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
||||
if hasattr(self, "username") and hasattr(self, "password"):
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.setConfiguration(userId, configuration)
|
||||
else:
|
||||
raise self.AuthenticationRequiredError
|
||||
else:
|
||||
raise self.UnknownError
|
||||
def getConfiguration(self, username="all", id="all"):
|
||||
|
||||
def getConfiguration(self, username: str = "all", userId: str = "all"):
|
||||
"""
|
||||
Gets a user's Configuration. This can also be found in getUsers if
|
||||
public is set to False.
|
||||
:param username: The user's username.
|
||||
:param id: The user's ID.
|
||||
:param userId: The user's ID.
|
||||
"""
|
||||
return self.getUsers(username=username,
|
||||
id=id,
|
||||
public=False)['Configuration']
|
||||
def getDisplayPreferences(self, userId):
|
||||
return self.getUsers(username=username, userId=userId, public=False)[
|
||||
"Configuration"
|
||||
]
|
||||
|
||||
def getDisplayPreferences(self, userId: str):
|
||||
"""
|
||||
Gets a user's Display Preferences (Home layout).
|
||||
:param userId: The user's ID.
|
||||
"""
|
||||
resp = requests.get((self.server+"/DisplayPreferences/usersettings" +
|
||||
"?userId="+userId+"&client=emby"),
|
||||
headers=self.header)
|
||||
resp = requests.get(
|
||||
(
|
||||
self.server
|
||||
+ "/DisplayPreferences/usersettings"
|
||||
+ "?userId="
|
||||
+ userId
|
||||
+ "&client=emby"
|
||||
),
|
||||
headers=self.header,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
elif resp.status_code == 401:
|
||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
||||
if hasattr(self, "username") and hasattr(self, "password"):
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.getDisplayPreferences(userId)
|
||||
else:
|
||||
raise self.AuthenticationRequiredError
|
||||
else:
|
||||
raise self.UnknownError
|
||||
def setDisplayPreferences(self, userId, preferences):
|
||||
|
||||
def setDisplayPreferences(self, userId: str, preferences: dict):
|
||||
"""
|
||||
Sets a user's Display Preferences (Home layout).
|
||||
:param userId: The user's ID.
|
||||
:param preferences: The preferences to set.
|
||||
"""
|
||||
tempheader = self.header
|
||||
tempheader['Content-type'] = 'application/json'
|
||||
resp = requests.post((self.server+"/DisplayPreferences/usersettings" +
|
||||
"?userId="+userId+"&client=emby"),
|
||||
headers=tempheader,
|
||||
json=preferences)
|
||||
if (resp.status_code == 200 or
|
||||
resp.status_code == 204):
|
||||
tempheader["Content-type"] = "application/json"
|
||||
resp = requests.post(
|
||||
(
|
||||
self.server
|
||||
+ "/DisplayPreferences/usersettings"
|
||||
+ "?userId="
|
||||
+ userId
|
||||
+ "&client=emby"
|
||||
),
|
||||
headers=tempheader,
|
||||
json=preferences,
|
||||
)
|
||||
if resp.status_code == 200 or resp.status_code == 204:
|
||||
return True
|
||||
elif resp.status_code == 401:
|
||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
||||
if hasattr(self, "username") and hasattr(self, "password"):
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.setDisplayPreferences(userId, preferences)
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# from flask import g
|
||||
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from itsdangerous import (TimedJSONWebSignatureSerializer
|
||||
as Serializer, BadSignature, SignatureExpired)
|
||||
from itsdangerous import (
|
||||
TimedJSONWebSignatureSerializer as Serializer,
|
||||
BadSignature,
|
||||
SignatureExpired,
|
||||
)
|
||||
from passlib.apps import custom_app_context as pwd_context
|
||||
import uuid
|
||||
from jellyfin_accounts import config, app, g
|
||||
@@ -11,13 +13,16 @@ from jellyfin_accounts import auth_log as log
|
||||
from jellyfin_accounts.jf_api import Jellyfin
|
||||
from jellyfin_accounts.web_api import jf
|
||||
|
||||
auth_jf = Jellyfin(config['jellyfin']['server'],
|
||||
config['jellyfin']['client'],
|
||||
config['jellyfin']['version'],
|
||||
config['jellyfin']['device'],
|
||||
config['jellyfin']['device_id'] + '_authClient')
|
||||
auth_jf = Jellyfin(
|
||||
config["jellyfin"]["server"],
|
||||
config["jellyfin"]["client"],
|
||||
config["jellyfin"]["version"],
|
||||
config["jellyfin"]["device"],
|
||||
config["jellyfin"]["device_id"] + "_authClient",
|
||||
)
|
||||
|
||||
class Account():
|
||||
|
||||
class Account:
|
||||
def __init__(self, username=None, password=None):
|
||||
self.username = username
|
||||
if password is not None:
|
||||
@@ -25,10 +30,12 @@ class Account():
|
||||
self.id = str(uuid.uuid4())
|
||||
self.jf = False
|
||||
elif username is not None:
|
||||
jf.authenticate(config['jellyfin']['username'],
|
||||
config['jellyfin']['password'])
|
||||
self.id = jf.getUsers(self.username, public=False)['Id']
|
||||
jf.authenticate(
|
||||
config["jellyfin"]["username"], config["jellyfin"]["password"]
|
||||
)
|
||||
self.id = jf.getUsers(self.username, public=False)["Id"]
|
||||
self.jf = True
|
||||
|
||||
def verify_password(self, password):
|
||||
if not self.jf:
|
||||
return pwd_context.verify(password, self.password_hash)
|
||||
@@ -37,59 +44,60 @@ class Account():
|
||||
return auth_jf.authenticate(self.username, password)
|
||||
except Jellyfin.AuthenticationError:
|
||||
return False
|
||||
|
||||
def generate_token(self, expiration=1200):
|
||||
s = Serializer(app.config['SECRET_KEY'], expires_in=expiration)
|
||||
s = Serializer(app.config["SECRET_KEY"], expires_in=expiration)
|
||||
log.debug(self.id)
|
||||
return s.dumps({ 'id': self.id })
|
||||
return s.dumps({"id": self.id})
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token, accounts):
|
||||
log.debug(f'verifying token {token}')
|
||||
s = Serializer(app.config['SECRET_KEY'])
|
||||
log.debug(f"verifying token {token}")
|
||||
s = Serializer(app.config["SECRET_KEY"])
|
||||
try:
|
||||
data = s.loads(token)
|
||||
except SignatureExpired:
|
||||
return None
|
||||
except BadSignature:
|
||||
return None
|
||||
if config.getboolean('ui', 'jellyfin_login'):
|
||||
if config.getboolean("ui", "jellyfin_login"):
|
||||
for account in accounts:
|
||||
if data['id'] == accounts[account].id:
|
||||
if data["id"] == accounts[account].id:
|
||||
return account
|
||||
else:
|
||||
return accounts['adminAccount']
|
||||
|
||||
return accounts["adminAccount"]
|
||||
|
||||
|
||||
auth = HTTPBasicAuth()
|
||||
|
||||
accounts = {}
|
||||
|
||||
if config.getboolean('ui', 'jellyfin_login'):
|
||||
log.debug('Using jellyfin for admin authentication')
|
||||
if config.getboolean("ui", "jellyfin_login"):
|
||||
log.debug("Using jellyfin for admin authentication")
|
||||
else:
|
||||
log.debug('Using configured login details for admin authentication')
|
||||
accounts['adminAccount'] = Account(config['ui']['username'],
|
||||
config['ui']['password'])
|
||||
log.debug("Using configured login details for admin authentication")
|
||||
accounts["adminAccount"] = Account(
|
||||
config["ui"]["username"], config["ui"]["password"]
|
||||
)
|
||||
|
||||
|
||||
@auth.verify_password
|
||||
def verify_password(username, password):
|
||||
user = None
|
||||
verified = False
|
||||
log.debug('Verifying auth')
|
||||
if config.getboolean('ui', 'jellyfin_login'):
|
||||
log.debug("Verifying auth")
|
||||
if config.getboolean("ui", "jellyfin_login"):
|
||||
try:
|
||||
jf_user = jf.getUsers(username, public=False)
|
||||
id = jf_user['Id']
|
||||
id = jf_user["Id"]
|
||||
user = accounts[id]
|
||||
except KeyError:
|
||||
if config.getboolean('ui', 'admin_only'):
|
||||
if jf_user['Policy']['IsAdministrator']:
|
||||
if config.getboolean("ui", "admin_only"):
|
||||
if jf_user["Policy"]["IsAdministrator"]:
|
||||
user = Account(username)
|
||||
accounts[id] = user
|
||||
else:
|
||||
log.debug(f'User {username} not admin.')
|
||||
log.debug(f"User {username} not admin.")
|
||||
return False
|
||||
else:
|
||||
user = Account(username)
|
||||
@@ -99,11 +107,11 @@ def verify_password(username, password):
|
||||
if user:
|
||||
verified = True
|
||||
if not user:
|
||||
log.debug(f'User {username} not found on Jellyfin')
|
||||
log.debug(f"User {username} not found on Jellyfin")
|
||||
return False
|
||||
else:
|
||||
user = accounts['adminAccount']
|
||||
verified = Account().verify_token(username, accounts)
|
||||
user = accounts["adminAccount"]
|
||||
verified = Account().verify_token(username, accounts)
|
||||
if not verified:
|
||||
if username == user.username and user.verify_password(password):
|
||||
g.user = user
|
||||
@@ -115,6 +123,3 @@ def verify_password(username, password):
|
||||
g.user = user
|
||||
log.debug("HTTPAuth Allowed")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from jellyfin_accounts import config, data_store
|
||||
from jellyfin_accounts import email_log as log
|
||||
|
||||
|
||||
|
||||
class Watcher:
|
||||
def __init__(self, dir):
|
||||
self.observer = Observer()
|
||||
@@ -20,13 +19,13 @@ class Watcher:
|
||||
try:
|
||||
self.observer.start()
|
||||
except NotADirectoryError:
|
||||
log.error(f'Directory {self.dir} does not exist')
|
||||
log.error(f"Directory {self.dir} does not exist")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(5)
|
||||
except:
|
||||
self.observer.stop()
|
||||
log.info('Watchdog stopped')
|
||||
log.info("Watchdog stopped")
|
||||
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
@@ -34,33 +33,35 @@ class Handler(FileSystemEventHandler):
|
||||
def on_any_event(event):
|
||||
if event.is_directory:
|
||||
return None
|
||||
elif (event.event_type == 'modified' and
|
||||
'passwordreset' in event.src_path):
|
||||
log.debug(f'Password reset file: {event.src_path}')
|
||||
elif event.event_type == "modified" and "passwordreset" in event.src_path:
|
||||
log.debug(f"Password reset file: {event.src_path}")
|
||||
time.sleep(1)
|
||||
with open(event.src_path, 'r') as f:
|
||||
with open(event.src_path, "r") as f:
|
||||
reset = json.load(f)
|
||||
log.info(f'New password reset for {reset["UserName"]}')
|
||||
try:
|
||||
id = jf.getUsers(reset['UserName'], public=False)['Id']
|
||||
id = jf.getUsers(reset["UserName"], public=False)["Id"]
|
||||
address = data_store.emails[id]
|
||||
if address != '':
|
||||
method = config['email']['method']
|
||||
if method == 'mailgun':
|
||||
if address != "":
|
||||
method = config["email"]["method"]
|
||||
if method == "mailgun":
|
||||
email = Mailgun(address)
|
||||
elif method == 'smtp':
|
||||
elif method == "smtp":
|
||||
email = Smtp(address)
|
||||
if email.construct_reset(reset):
|
||||
email.send()
|
||||
else:
|
||||
raise IndexError
|
||||
except (FileNotFoundError,
|
||||
json.decoder.JSONDecodeError,
|
||||
IndexError) as e:
|
||||
err = f'{address}: Failed: ' + type(e).__name__
|
||||
except (
|
||||
FileNotFoundError,
|
||||
json.decoder.JSONDecodeError,
|
||||
IndexError,
|
||||
) as e:
|
||||
err = f"{address}: Failed: " + type(e).__name__
|
||||
log.error(err)
|
||||
|
||||
|
||||
def start():
|
||||
log.info(f'Monitoring {config["password_resets"]["watch_directory"]}')
|
||||
w = Watcher(config['password_resets']['watch_directory'])
|
||||
w = Watcher(config["password_resets"]["watch_directory"])
|
||||
w.run()
|
||||
|
||||
@@ -1,50 +1,41 @@
|
||||
from flask import request, jsonify, render_template
|
||||
from configparser import RawConfigParser
|
||||
from jellyfin_accounts.jf_api import Jellyfin
|
||||
from jellyfin_accounts import config, config_path, app, first_run
|
||||
from jellyfin_accounts import config, config_path, app, first_run, resp
|
||||
from jellyfin_accounts import web_log as log
|
||||
import os
|
||||
|
||||
if first_run:
|
||||
def resp(success=True, code=500):
|
||||
if success:
|
||||
r = jsonify({'success': True})
|
||||
r.status_code = 200
|
||||
else:
|
||||
r = jsonify({'success': False})
|
||||
r.status_code = code
|
||||
return r
|
||||
|
||||
def tempJF(server):
|
||||
return Jellyfin(server,
|
||||
config['jellyfin']['client'],
|
||||
config['jellyfin']['version'],
|
||||
config['jellyfin']['device'] + '_temp',
|
||||
config['jellyfin']['device_id'] + '_temp')
|
||||
return Jellyfin(
|
||||
server,
|
||||
config["jellyfin"]["client"],
|
||||
config["jellyfin"]["version"],
|
||||
config["jellyfin"]["device"] + "_temp",
|
||||
config["jellyfin"]["device_id"] + "_temp",
|
||||
)
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template('404.html'), 404
|
||||
return render_template("404.html"), 404
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def setup():
|
||||
return render_template('setup.html')
|
||||
return render_template("setup.html")
|
||||
|
||||
|
||||
@app.route('/<path:path>')
|
||||
@app.route("/<path:path>")
|
||||
def static_proxy(path):
|
||||
if 'html' not in path:
|
||||
if "html" not in path:
|
||||
return app.send_static_file(path)
|
||||
else:
|
||||
return render_template('404.html'), 404
|
||||
return render_template("404.html"), 404
|
||||
|
||||
|
||||
@app.route('/modifyConfig', methods=['POST'])
|
||||
@app.route("/modifyConfig", methods=["POST"])
|
||||
def modifyConfig():
|
||||
log.info('Config modification requested')
|
||||
log.info("Config modification requested")
|
||||
data = request.get_json()
|
||||
temp_config = RawConfigParser(comment_prefixes='/',
|
||||
allow_no_value=True)
|
||||
temp_config = RawConfigParser(comment_prefixes="/", allow_no_value=True)
|
||||
temp_config.read(config_path)
|
||||
for section in data:
|
||||
if section in temp_config:
|
||||
@@ -52,24 +43,23 @@ if first_run:
|
||||
if item in temp_config[section]:
|
||||
temp_config[section][item] = data[section][item]
|
||||
data[section][item] = True
|
||||
log.debug(f'{section}/{item} modified')
|
||||
log.debug(f"{section}/{item} modified")
|
||||
else:
|
||||
data[section][item] = False
|
||||
log.debug(f'{section}/{item} does not exist in config')
|
||||
with open(config_path, 'w') as config_file:
|
||||
log.debug(f"{section}/{item} does not exist in config")
|
||||
with open(config_path, "w") as config_file:
|
||||
temp_config.write(config_file)
|
||||
log.debug('Config written')
|
||||
log.debug("Config written")
|
||||
# ugly exit, sorry
|
||||
os._exit(1)
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/testJF', methods=['GET', 'POST'])
|
||||
@app.route("/testJF", methods=["GET", "POST"])
|
||||
def testJF():
|
||||
data = request.get_json()
|
||||
tempjf = tempJF(data['jfHost'])
|
||||
tempjf = tempJF(data["jfHost"])
|
||||
try:
|
||||
tempjf.authenticate(data['jfUser'],
|
||||
data['jfPassword'])
|
||||
tempjf.authenticate(data["jfUser"], data["jfPassword"])
|
||||
tempjf.getUsers(public=False)
|
||||
return resp()
|
||||
except:
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')',
|
||||
'<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']']
|
||||
|
||||
|
||||
class PasswordValidator:
|
||||
def __init__(self, min_length, upper, lower, number, special):
|
||||
self.criteria = {'characters': int(min_length),
|
||||
'uppercase characters': int(upper),
|
||||
'lowercase characters': int(lower),
|
||||
'numbers': int(number),
|
||||
'special characters': int(special)}
|
||||
self.criteria = {
|
||||
"characters": int(min_length),
|
||||
"uppercase characters": int(upper),
|
||||
"lowercase characters": int(lower),
|
||||
"numbers": int(number),
|
||||
"special characters": int(special),
|
||||
}
|
||||
|
||||
def validate(self, password):
|
||||
count = {'characters': 0,
|
||||
'uppercase characters': 0,
|
||||
'lowercase characters': 0,
|
||||
'numbers': 0,
|
||||
'special characters': 0}
|
||||
count = {
|
||||
"characters": 0,
|
||||
"uppercase characters": 0,
|
||||
"lowercase characters": 0,
|
||||
"numbers": 0,
|
||||
"special characters": 0,
|
||||
}
|
||||
for c in password:
|
||||
count['characters'] += 1
|
||||
count["characters"] += 1
|
||||
if c.isupper():
|
||||
count['uppercase characters'] += 1
|
||||
count["uppercase characters"] += 1
|
||||
elif c.islower():
|
||||
count['lowercase characters'] += 1
|
||||
count["lowercase characters"] += 1
|
||||
elif c.isnumeric():
|
||||
count['numbers'] += 1
|
||||
count["numbers"] += 1
|
||||
elif c in specials:
|
||||
count['special characters'] += 1
|
||||
count["special characters"] += 1
|
||||
for criterion in count:
|
||||
if count[criterion] < self.criteria[criterion]:
|
||||
count[criterion] = False
|
||||
else:
|
||||
count[criterion] = True
|
||||
return count
|
||||
|
||||
def getCriteria(self):
|
||||
lines = {}
|
||||
for criterion in self.criteria:
|
||||
@@ -42,8 +49,3 @@ class PasswordValidator:
|
||||
text += criterion
|
||||
lines[criterion] = text
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,70 +1,84 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from flask import Flask, send_from_directory, render_template
|
||||
from jellyfin_accounts import config, app, g, css, data_store
|
||||
|
||||
from jellyfin_accounts import app, g, css, data_store
|
||||
from jellyfin_accounts import web_log as log
|
||||
from jellyfin_accounts.web_api import checkInvite, validator
|
||||
from jellyfin_accounts.web_api import config, checkInvite, validator
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template('404.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage=config['ui']['contact_message']), 404
|
||||
return (
|
||||
render_template(
|
||||
"404.html",
|
||||
css_href=css["href"],
|
||||
css_integrity=css["integrity"],
|
||||
css_crossorigin=css["crossorigin"],
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def admin():
|
||||
# return app.send_static_file('admin.html')
|
||||
return render_template('admin.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage='',
|
||||
email_enabled=config.getboolean(
|
||||
'invite_emails', 'enabled'))
|
||||
return render_template(
|
||||
"admin.html",
|
||||
css_href=css["href"],
|
||||
css_integrity=css["integrity"],
|
||||
css_crossorigin=css["crossorigin"],
|
||||
contactMessage="",
|
||||
email_enabled=config.getboolean("invite_emails", "enabled"),
|
||||
)
|
||||
|
||||
|
||||
@app.route('/<path:path>')
|
||||
@app.route("/<path:path>")
|
||||
def static_proxy(path):
|
||||
if 'html' not in path:
|
||||
if "html" not in path:
|
||||
return app.send_static_file(path)
|
||||
return render_template('404.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage=config['ui']['contact_message']), 404
|
||||
return (
|
||||
render_template(
|
||||
"404.html",
|
||||
css_href=css["href"],
|
||||
css_integrity=css["integrity"],
|
||||
css_crossorigin=css["crossorigin"],
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/invite/<path:path>')
|
||||
@app.route("/invite/<path:path>")
|
||||
def inviteProxy(path):
|
||||
if checkInvite(path):
|
||||
log.info(f'Invite {path} used to request form')
|
||||
log.info(f"Invite {path} used to request form")
|
||||
try:
|
||||
email = data_store.invites[path]['email']
|
||||
email = data_store.invites[path]["email"]
|
||||
except KeyError:
|
||||
email = ''
|
||||
return render_template('form.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage=config['ui']['contact_message'],
|
||||
helpMessage=config['ui']['help_message'],
|
||||
successMessage=config['ui']['success_message'],
|
||||
jfLink=config['jellyfin']['public_server'],
|
||||
validate=config.getboolean(
|
||||
'password_validation',
|
||||
'enabled'),
|
||||
requirements=validator.getCriteria(),
|
||||
email=email)
|
||||
elif 'admin.html' not in path and 'admin.html' not in path:
|
||||
email = ""
|
||||
return render_template(
|
||||
"form.html",
|
||||
css_href=css["href"],
|
||||
css_integrity=css["integrity"],
|
||||
css_crossorigin=css["crossorigin"],
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
helpMessage=config["ui"]["help_message"],
|
||||
successMessage=config["ui"]["success_message"],
|
||||
jfLink=config["jellyfin"]["public_server"],
|
||||
validate=config.getboolean("password_validation", "enabled"),
|
||||
requirements=validator.getCriteria(),
|
||||
email=email,
|
||||
username=(not config.getboolean("email", "no_username")),
|
||||
)
|
||||
elif "admin.html" not in path and "admin.html" not in path:
|
||||
return app.send_static_file(path)
|
||||
else:
|
||||
log.debug('Attempted use of invalid invite')
|
||||
return render_template('invalidCode.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage=config['ui']['contact_message'])
|
||||
log.debug("Attempted use of invalid invite")
|
||||
return render_template(
|
||||
"invalidCode.html",
|
||||
css_href=css["href"],
|
||||
css_integrity=css["integrity"],
|
||||
css_crossorigin=css["crossorigin"],
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
)
|
||||
|
||||
@@ -4,31 +4,32 @@ import json
|
||||
import datetime
|
||||
import secrets
|
||||
import time
|
||||
from jellyfin_accounts import config, config_path, app, g, data_store
|
||||
from jellyfin_accounts import (
|
||||
config,
|
||||
config_path,
|
||||
load_config,
|
||||
data_dir,
|
||||
app,
|
||||
g,
|
||||
data_store,
|
||||
resp,
|
||||
configparser,
|
||||
config_base_path,
|
||||
)
|
||||
from jellyfin_accounts import web_log as log
|
||||
from jellyfin_accounts.validate_password import PasswordValidator
|
||||
|
||||
def resp(success=True, code=500):
|
||||
if success:
|
||||
r = jsonify({'success': True})
|
||||
if code == 500:
|
||||
r.status_code = 200
|
||||
else:
|
||||
r.status_code = code
|
||||
else:
|
||||
r = jsonify({'success': False})
|
||||
r.status_code = code
|
||||
return r
|
||||
|
||||
def checkInvite(code, delete=False):
|
||||
current_time = datetime.datetime.now()
|
||||
invites = dict(data_store.invites)
|
||||
match = False
|
||||
for invite in invites:
|
||||
expiry = datetime.datetime.strptime(invites[invite]['valid_till'],
|
||||
'%Y-%m-%dT%H:%M:%S.%f')
|
||||
expiry = datetime.datetime.strptime(
|
||||
invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
|
||||
)
|
||||
if current_time >= expiry:
|
||||
log.debug(f'Housekeeping: Deleting old invite {invite}')
|
||||
log.debug(f"Housekeeping: Deleting old invite {invite}")
|
||||
del data_store.invites[invite]
|
||||
elif invite == code:
|
||||
match = True
|
||||
@@ -36,34 +37,39 @@ def checkInvite(code, delete=False):
|
||||
del data_store.invites[code]
|
||||
return match
|
||||
|
||||
jf = Jellyfin(config['jellyfin']['server'],
|
||||
config['jellyfin']['client'],
|
||||
config['jellyfin']['version'],
|
||||
config['jellyfin']['device'],
|
||||
config['jellyfin']['device_id'])
|
||||
|
||||
jf = Jellyfin(
|
||||
config["jellyfin"]["server"],
|
||||
config["jellyfin"]["client"],
|
||||
config["jellyfin"]["version"],
|
||||
config["jellyfin"]["device"],
|
||||
config["jellyfin"]["device_id"],
|
||||
)
|
||||
|
||||
from jellyfin_accounts.login import auth
|
||||
|
||||
jf_address = config['jellyfin']['server']
|
||||
jf_address = config["jellyfin"]["server"]
|
||||
success = False
|
||||
for i in range(3):
|
||||
try:
|
||||
jf.authenticate(config['jellyfin']['username'],
|
||||
config['jellyfin']['password'])
|
||||
jf.authenticate(config["jellyfin"]["username"], config["jellyfin"]["password"])
|
||||
success = True
|
||||
log.info(f'Successfully authenticated with {jf_address}')
|
||||
log.info(f"Successfully authenticated with {jf_address}")
|
||||
break
|
||||
except Jellyfin.AuthenticationError:
|
||||
log.error(f'Failed to authenticate with {jf_address}, Retrying...')
|
||||
log.error(f"Failed to authenticate with {jf_address}, Retrying...")
|
||||
time.sleep(5)
|
||||
|
||||
if not success:
|
||||
log.error('Could not authenticate after 3 tries.')
|
||||
log.error("Could not authenticate after 3 tries.")
|
||||
exit()
|
||||
|
||||
# Temporary fixes below.
|
||||
|
||||
|
||||
def switchToIds():
|
||||
try:
|
||||
with open(config['files']['emails'], 'r') as f:
|
||||
with open(config["files"]["emails"], "r") as f:
|
||||
emails = json.load(f)
|
||||
except (FileNotFoundError, json.decoder.JSONDecodeError):
|
||||
emails = {}
|
||||
@@ -72,220 +78,297 @@ def switchToIds():
|
||||
match = False
|
||||
for key in emails:
|
||||
for user in users:
|
||||
if user['Name'] == key:
|
||||
if user["Name"] == key:
|
||||
match = True
|
||||
new_emails[user['Id']] = emails[key]
|
||||
elif user['Id'] == key:
|
||||
new_emails[user['Id']] = emails[key]
|
||||
new_emails[user["Id"]] = emails[key]
|
||||
elif user["Id"] == key:
|
||||
new_emails[user["Id"]] = emails[key]
|
||||
if match:
|
||||
from pathlib import Path
|
||||
email_file = Path(config['files']['emails']).name
|
||||
log.info((f'{email_file} modified to use userID instead of ' +
|
||||
'usernames. These will be used in future.'))
|
||||
|
||||
email_file = Path(config["files"]["emails"]).name
|
||||
log.info(
|
||||
(
|
||||
f"{email_file} modified to use userID instead of "
|
||||
+ "usernames. These will be used in future."
|
||||
)
|
||||
)
|
||||
emails = new_emails
|
||||
with open(config['files']['emails'], 'w') as f:
|
||||
with open(config["files"]["emails"], "w") as f:
|
||||
f.write(json.dumps(emails, indent=4))
|
||||
|
||||
|
||||
# Temporary, switches emails.json over from using Usernames to User IDs.
|
||||
switchToIds()
|
||||
|
||||
if config.getboolean('password_validation', 'enabled'):
|
||||
validator = PasswordValidator(config['password_validation']['min_length'],
|
||||
config['password_validation']['upper'],
|
||||
config['password_validation']['lower'],
|
||||
config['password_validation']['number'],
|
||||
config['password_validation']['special'])
|
||||
|
||||
from packaging import version
|
||||
|
||||
if (
|
||||
version.parse(jf.info["Version"]) >= version.parse("10.6.0")
|
||||
and bool(data_store.user_template) is not False
|
||||
):
|
||||
log.info("Updating user_template for Jellyfin >= 10.6.0")
|
||||
if (
|
||||
data_store.user_template["AuthenticationProviderId"]
|
||||
== "Emby.Server.Implementations.Library.DefaultAuthenticationProvider"
|
||||
):
|
||||
data_store.user_template[
|
||||
"AuthenticationProviderId"
|
||||
] = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider"
|
||||
if (
|
||||
data_store.user_template["PasswordResetProviderId"]
|
||||
== "Emby.Server.Implementations.Library.DefaultPasswordResetProvider"
|
||||
):
|
||||
data_store.user_template[
|
||||
"PasswordResetProviderId"
|
||||
] = "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider"
|
||||
|
||||
|
||||
if config.getboolean("password_validation", "enabled"):
|
||||
validator = PasswordValidator(
|
||||
config["password_validation"]["min_length"],
|
||||
config["password_validation"]["upper"],
|
||||
config["password_validation"]["lower"],
|
||||
config["password_validation"]["number"],
|
||||
config["password_validation"]["special"],
|
||||
)
|
||||
else:
|
||||
validator = PasswordValidator(0, 0, 0, 0, 0)
|
||||
|
||||
|
||||
@app.route('/newUser', methods=['POST'])
|
||||
@app.route("/newUser", methods=["POST"])
|
||||
def newUser():
|
||||
data = request.get_json()
|
||||
log.debug('Attempted newUser')
|
||||
if checkInvite(data['code']):
|
||||
validation = validator.validate(data['password'])
|
||||
log.debug("Attempted newUser")
|
||||
if checkInvite(data["code"]):
|
||||
validation = validator.validate(data["password"])
|
||||
valid = True
|
||||
for criterion in validation:
|
||||
if validation[criterion] is False:
|
||||
valid = False
|
||||
if valid:
|
||||
log.debug('User password valid')
|
||||
log.debug("User password valid")
|
||||
try:
|
||||
user = jf.newUser(data['username'],
|
||||
data['password'])
|
||||
user = jf.newUser(data["username"], data["password"])
|
||||
except Jellyfin.UserExistsError:
|
||||
error = f'User already exists named {data["username"]}'
|
||||
log.debug(error)
|
||||
return jsonify({'error': error})
|
||||
return jsonify({"error": error})
|
||||
except:
|
||||
return jsonify({'error': 'Unknown error'})
|
||||
checkInvite(data['code'], delete=True)
|
||||
return jsonify({"error": "Unknown error"})
|
||||
checkInvite(data["code"], delete=True)
|
||||
if user.status_code == 200:
|
||||
try:
|
||||
policy = data_store.user_template
|
||||
if policy != {}:
|
||||
jf.setPolicy(user.json()['Id'], policy)
|
||||
jf.setPolicy(user.json()["Id"], policy)
|
||||
else:
|
||||
log.debug('user policy was blank')
|
||||
log.debug("user policy was blank")
|
||||
except:
|
||||
log.error('Failed to set new user policy')
|
||||
log.error("Failed to set new user policy")
|
||||
try:
|
||||
configuration = data_store.user_configuration
|
||||
displayprefs = data_store.user_displayprefs
|
||||
if configuration != {} and displayprefs != {}:
|
||||
if jf.setConfiguration(user.json()['Id'],
|
||||
configuration):
|
||||
jf.setDisplayPreferences(user.json()['Id'],
|
||||
displayprefs)
|
||||
log.debug('Set homescreen layout.')
|
||||
if jf.setConfiguration(user.json()["Id"], configuration):
|
||||
jf.setDisplayPreferences(user.json()["Id"], displayprefs)
|
||||
log.debug("Set homescreen layout.")
|
||||
else:
|
||||
log.debug('user configuration and/or ' +
|
||||
'displayprefs were blank')
|
||||
log.debug(
|
||||
"user configuration and/or " + "displayprefs were blank"
|
||||
)
|
||||
except:
|
||||
log.error('Failed to set new user homescreen layout')
|
||||
if config.getboolean('password_resets', 'enabled'):
|
||||
data_store.emails[user.json()['Id']] = data['email']
|
||||
log.debug('Email address stored')
|
||||
log.info('New user created')
|
||||
log.error("Failed to set new user homescreen layout")
|
||||
if config.getboolean("password_resets", "enabled"):
|
||||
data_store.emails[user.json()["Id"]] = data["email"]
|
||||
log.debug("Email address stored")
|
||||
log.info("New user created")
|
||||
else:
|
||||
log.error(f'New user creation failed: {user.status_code}')
|
||||
log.error(f"New user creation failed: {user.status_code}")
|
||||
return resp(False)
|
||||
else:
|
||||
log.debug('User password invalid')
|
||||
log.debug("User password invalid")
|
||||
return jsonify(validation)
|
||||
else:
|
||||
log.debug('Attempted newUser unauthorized')
|
||||
log.debug("Attempted newUser unauthorized")
|
||||
return resp(False, code=401)
|
||||
|
||||
|
||||
@app.route('/generateInvite', methods=['POST'])
|
||||
@app.route("/generateInvite", methods=["POST"])
|
||||
@auth.login_required
|
||||
def generateInvite():
|
||||
current_time = datetime.datetime.now()
|
||||
data = request.get_json()
|
||||
delta = datetime.timedelta(hours=int(data['hours']),
|
||||
minutes=int(data['minutes']))
|
||||
delta = datetime.timedelta(hours=int(data["hours"]), minutes=int(data["minutes"]))
|
||||
invite_code = secrets.token_urlsafe(16)
|
||||
invite = {}
|
||||
log.debug(f'Creating new invite: {invite_code}')
|
||||
log.debug(f"Creating new invite: {invite_code}")
|
||||
valid_till = current_time + delta
|
||||
invite['valid_till'] = valid_till.strftime('%Y-%m-%dT%H:%M:%S.%f')
|
||||
if 'email' in data and config.getboolean('invite_emails', 'enabled'):
|
||||
address = data['email']
|
||||
invite['email'] = address
|
||||
log.info(f'Sending invite to {address}')
|
||||
method = config['email']['method']
|
||||
if method == 'mailgun':
|
||||
invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f")
|
||||
if "email" in data and config.getboolean("invite_emails", "enabled"):
|
||||
address = data["email"]
|
||||
invite["email"] = address
|
||||
log.info(f"Sending invite to {address}")
|
||||
method = config["email"]["method"]
|
||||
if method == "mailgun":
|
||||
from jellyfin_accounts.email import Mailgun
|
||||
|
||||
email = Mailgun(address)
|
||||
elif method == 'smtp':
|
||||
elif method == "smtp":
|
||||
from jellyfin_accounts.email import Smtp
|
||||
|
||||
email = Smtp(address)
|
||||
email.construct_invite({'expiry': valid_till,
|
||||
'code': invite_code})
|
||||
email.construct_invite({"expiry": valid_till, "code": invite_code})
|
||||
response = email.send()
|
||||
if response is False or type(response) != bool:
|
||||
invite['email'] = f'Failed to send to {address}'
|
||||
invite["email"] = f"Failed to send to {address}"
|
||||
data_store.invites[invite_code] = invite
|
||||
log.info(f'New invite created: {invite_code}')
|
||||
log.info(f"New invite created: {invite_code}")
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/getInvites', methods=['GET'])
|
||||
@app.route("/getInvites", methods=["GET"])
|
||||
@auth.login_required
|
||||
def getInvites():
|
||||
log.debug('Invites requested')
|
||||
log.debug("Invites requested")
|
||||
current_time = datetime.datetime.now()
|
||||
invites = dict(data_store.invites)
|
||||
for code in invites:
|
||||
checkInvite(code)
|
||||
invites = dict(data_store.invites)
|
||||
response = {'invites': []}
|
||||
response = {"invites": []}
|
||||
for code in invites:
|
||||
expiry = datetime.datetime.strptime(invites[code]['valid_till'],
|
||||
'%Y-%m-%dT%H:%M:%S.%f')
|
||||
expiry = datetime.datetime.strptime(
|
||||
invites[code]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
|
||||
)
|
||||
valid_for = expiry - current_time
|
||||
invite = {'code': code,
|
||||
'hours': valid_for.seconds//3600,
|
||||
'minutes': (valid_for.seconds//60) % 60}
|
||||
if 'email' in invites[code]:
|
||||
invite['email'] = invites[code]['email']
|
||||
response['invites'].append(invite)
|
||||
invite = {
|
||||
"code": code,
|
||||
"hours": valid_for.seconds // 3600,
|
||||
"minutes": (valid_for.seconds // 60) % 60,
|
||||
}
|
||||
if "email" in invites[code]:
|
||||
invite["email"] = invites[code]["email"]
|
||||
response["invites"].append(invite)
|
||||
return jsonify(response)
|
||||
|
||||
@app.route('/deleteInvite', methods=['POST'])
|
||||
|
||||
@app.route("/deleteInvite", methods=["POST"])
|
||||
@auth.login_required
|
||||
def deleteInvite():
|
||||
code = request.get_json()['code']
|
||||
code = request.get_json()["code"]
|
||||
invites = dict(data_store.invites)
|
||||
if code in invites:
|
||||
del data_store.invites[code]
|
||||
log.info(f'Invite deleted: {code}')
|
||||
log.info(f"Invite deleted: {code}")
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/getToken')
|
||||
@app.route("/getToken")
|
||||
@auth.login_required
|
||||
def get_token():
|
||||
token = g.user.generate_token()
|
||||
return jsonify({'token': token.decode('ascii')})
|
||||
return jsonify({"token": token.decode("ascii")})
|
||||
|
||||
|
||||
@app.route('/getUsers', methods=['GET'])
|
||||
@app.route("/getUsers", methods=["GET"])
|
||||
@auth.login_required
|
||||
def getUsers():
|
||||
log.debug('User and email list requested')
|
||||
response = {'users': []}
|
||||
log.debug("User and email list requested")
|
||||
response = {"users": []}
|
||||
users = jf.getUsers(public=False)
|
||||
emails = data_store.emails
|
||||
for user in users:
|
||||
entry = {'name': user['Name']}
|
||||
if user['Id'] in emails:
|
||||
entry['email'] = emails[user['Id']]
|
||||
response['users'].append(entry)
|
||||
entry = {"name": user["Name"]}
|
||||
if user["Id"] in emails:
|
||||
entry["email"] = emails[user["Id"]]
|
||||
response["users"].append(entry)
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@app.route('/modifyUsers', methods=['POST'])
|
||||
@app.route("/modifyUsers", methods=["POST"])
|
||||
@auth.login_required
|
||||
def modifyUsers():
|
||||
data = request.get_json()
|
||||
log.debug('Email list modification requested')
|
||||
log.debug("Email list modification requested")
|
||||
for key in data:
|
||||
uid = jf.getUsers(key, public=False)['Id']
|
||||
uid = jf.getUsers(key, public=False)["Id"]
|
||||
data_store.emails[uid] = data[key]
|
||||
log.debug(f'Email for user "{key}" modified')
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/setDefaults', methods=['POST'])
|
||||
@app.route("/setDefaults", methods=["POST"])
|
||||
@auth.login_required
|
||||
def setDefaults():
|
||||
data = request.get_json()
|
||||
username = data['username']
|
||||
log.debug(f'Storing default settings from user {username}')
|
||||
username = data["username"]
|
||||
log.debug(f"Storing default settings from user {username}")
|
||||
try:
|
||||
user = jf.getUsers(username=username,
|
||||
public=False)
|
||||
user = jf.getUsers(username=username, public=False)
|
||||
except Jellyfin.UserNotFoundError:
|
||||
log.error(f'Storing defaults failed: Couldn\'t find user {username}')
|
||||
log.error(f"Storing defaults failed: Couldn't find user {username}")
|
||||
return resp(False)
|
||||
uid = user['Id']
|
||||
policy = user['Policy']
|
||||
uid = user["Id"]
|
||||
policy = user["Policy"]
|
||||
data_store.user_template = policy
|
||||
if data['homescreen']:
|
||||
configuration = user['Configuration']
|
||||
if data["homescreen"]:
|
||||
configuration = user["Configuration"]
|
||||
try:
|
||||
displayprefs = jf.getDisplayPreferences(uid)
|
||||
data_store.user_configuration = configuration
|
||||
data_store.user_displayprefs = displayprefs
|
||||
except:
|
||||
log.error('Storing defaults failed: ' +
|
||||
'couldn\'t store homescreen layout')
|
||||
log.error("Storing defaults failed: " + "couldn't store homescreen layout")
|
||||
return resp(False)
|
||||
return resp()
|
||||
|
||||
import jellyfin_accounts.setup
|
||||
|
||||
|
||||
@app.route("/modifyConfig", methods=["POST"])
|
||||
@auth.login_required
|
||||
def modifyConfig():
|
||||
global config
|
||||
log.info("Config modification requested")
|
||||
data = request.get_json()
|
||||
temp_config = configparser.RawConfigParser(
|
||||
comment_prefixes="/", allow_no_value=True
|
||||
)
|
||||
temp_config.read(config_path)
|
||||
for section in data:
|
||||
if section in temp_config:
|
||||
for item in data[section]:
|
||||
if item in temp_config[section]:
|
||||
temp_config[section][item] = data[section][item]
|
||||
data[section][item] = True
|
||||
log.debug(f"{section}/{item} modified")
|
||||
else:
|
||||
data[section][item] = False
|
||||
log.debug(f"{section}/{item} does not exist in config")
|
||||
with open(config_path, "w") as config_file:
|
||||
temp_config.write(config_file)
|
||||
config = load_config(config_path, data_dir)
|
||||
log.info("Config written. Restart may be needed to load settings.")
|
||||
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"])
|
||||
@auth.login_required
|
||||
def getConfig():
|
||||
log.debug("Config requested")
|
||||
with open(config_base_path, "r") as f:
|
||||
config_base = json.load(f)
|
||||
# config.read(config_path)
|
||||
response_config = config_base
|
||||
for section in config_base:
|
||||
for entry in config_base[section]:
|
||||
if entry in config[section]:
|
||||
response_config[section][entry]["value"] = config[section][entry]
|
||||
return jsonify(response_config), 200
|
||||
|
||||
280
poetry.lock
generated
280
poetry.lock
generated
@@ -1,3 +1,45 @@
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
name = "appdirs"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.4.4"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Classes Without Boilerplate"
|
||||
name = "attrs"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "19.3.0"
|
||||
|
||||
[package.extras]
|
||||
azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
|
||||
dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
|
||||
docs = ["sphinx", "zope.interface"]
|
||||
tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "The uncompromising code formatter."
|
||||
name = "black"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "19.10b0"
|
||||
|
||||
[package.dependencies]
|
||||
appdirs = "*"
|
||||
attrs = ">=18.1.0"
|
||||
click = ">=6.5"
|
||||
pathspec = ">=0.6,<1"
|
||||
regex = "*"
|
||||
toml = ">=0.9.4"
|
||||
typed-ast = ">=1.4.0"
|
||||
|
||||
[package.extras]
|
||||
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
@@ -33,18 +75,6 @@ optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "7.1.2"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Updated configparser from Python 3.8 for Python 2.6+."
|
||||
name = "configparser"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "5.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
|
||||
testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-black-multipy", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
@@ -89,11 +119,19 @@ description = "Basic and Digest HTTP authentication for Flask routes"
|
||||
name = "flask-httpauth"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "4.1.0"
|
||||
version = "3.3.0"
|
||||
|
||||
[package.dependencies]
|
||||
Flask = "*"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Lightweight in-process concurrent programming"
|
||||
name = "greenlet"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.4.16"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
@@ -132,6 +170,37 @@ optional = false
|
||||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
|
||||
version = "1.1.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "MessagePack (de)serializer."
|
||||
name = "msgpack"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.0.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Transition packgage for pynvim"
|
||||
name = "neovim"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.3.1"
|
||||
|
||||
[package.dependencies]
|
||||
pynvim = ">=0.3.1"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Core utilities for Python packages"
|
||||
name = "packaging"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "20.4"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2"
|
||||
six = "*"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "comprehensive password hashing framework supporting over 30 schemes"
|
||||
@@ -146,6 +215,14 @@ bcrypt = ["bcrypt (>=3.1.0)"]
|
||||
build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.0)"]
|
||||
totp = ["cryptography"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
name = "pathspec"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "File system general utilities"
|
||||
@@ -162,6 +239,22 @@ optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "2.20"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python client to neovim"
|
||||
name = "pynvim"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.4.1"
|
||||
|
||||
[package.dependencies]
|
||||
greenlet = "*"
|
||||
msgpack = ">=0.5.0"
|
||||
|
||||
[package.extras]
|
||||
pyuv = ["pyuv (>=1.0.0)"]
|
||||
test = ["pytest (>=3.4.0)"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python wrapper module around the OpenSSL library"
|
||||
@@ -178,6 +271,14 @@ six = ">=1.5.2"
|
||||
docs = ["sphinx", "sphinx-rtd-theme"]
|
||||
test = ["flaky", "pretend", "pytest (>=3.0.1)"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python parsing module"
|
||||
name = "pyparsing"
|
||||
optional = false
|
||||
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
version = "2.4.7"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Extensions to the standard Python datetime module"
|
||||
@@ -197,6 +298,14 @@ optional = false
|
||||
python-versions = "*"
|
||||
version = "2020.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Alternative regular expression module, to replace re."
|
||||
name = "regex"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "2020.6.8"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python HTTP for Humans."
|
||||
@@ -223,6 +332,22 @@ optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
version = "1.15.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python Library for Tom's Obvious, Minimal Language"
|
||||
name = "toml"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.10.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "a fork of Python 2 and 3 ast modules with type comment support"
|
||||
name = "typed-ast"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.4.1"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
@@ -275,10 +400,22 @@ dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-
|
||||
watchdog = ["watchdog"]
|
||||
|
||||
[metadata]
|
||||
content-hash = "721be13a1e348d7e424529ba8466b9e2408df2cd97ab45e7e0d2f665b3213879"
|
||||
content-hash = "847ce2a6a3927efdfb3b78935b348e9b4dc63d7e60959af6cc8b9fbc5a24567b"
|
||||
python-versions = "^3.6"
|
||||
|
||||
[metadata.files]
|
||||
appdirs = [
|
||||
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
|
||||
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
|
||||
]
|
||||
attrs = [
|
||||
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
|
||||
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
|
||||
]
|
||||
black = [
|
||||
{file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
|
||||
{file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
|
||||
]
|
||||
certifi = [
|
||||
{file = "certifi-2020.4.5.2-py2.py3-none-any.whl", hash = "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc"},
|
||||
{file = "certifi-2020.4.5.2.tar.gz", hash = "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1"},
|
||||
@@ -321,10 +458,6 @@ click = [
|
||||
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
|
||||
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
|
||||
]
|
||||
configparser = [
|
||||
{file = "configparser-5.0.0-py3-none-any.whl", hash = "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd"},
|
||||
{file = "configparser-5.0.0.tar.gz", hash = "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1"},
|
||||
]
|
||||
cryptography = [
|
||||
{file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"},
|
||||
{file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"},
|
||||
@@ -351,8 +484,27 @@ flask = [
|
||||
{file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"},
|
||||
]
|
||||
flask-httpauth = [
|
||||
{file = "Flask-HTTPAuth-4.1.0.tar.gz", hash = "sha256:9e028e4375039a49031eb9ecc40be4761f0540476040f6eff329a31dabd4d000"},
|
||||
{file = "Flask_HTTPAuth-4.1.0-py2.py3-none-any.whl", hash = "sha256:29e0288869a213c7387f0323b6bf2c7191584fb1da8aa024d9af118e5cd70de7"},
|
||||
{file = "Flask-HTTPAuth-3.3.0.tar.gz", hash = "sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9"},
|
||||
{file = "Flask_HTTPAuth-3.3.0-py2.py3-none-any.whl", hash = "sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72"},
|
||||
]
|
||||
greenlet = [
|
||||
{file = "greenlet-0.4.16-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872"},
|
||||
{file = "greenlet-0.4.16-cp27-cp27m-win32.whl", hash = "sha256:df7de669cbf21de4b04a3ffc9920bc8426cab4c61365fa84d79bf97401a8bef7"},
|
||||
{file = "greenlet-0.4.16-cp27-cp27m-win_amd64.whl", hash = "sha256:1429dc183b36ec972055e13250d96e174491559433eb3061691b446899b87384"},
|
||||
{file = "greenlet-0.4.16-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5ea034d040e6ab1d2ae04ab05a3f37dbd719c4dee3804b13903d4cc794b1336e"},
|
||||
{file = "greenlet-0.4.16-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c196a5394c56352e21cb7224739c6dd0075b69dd56f758505951d1d8d68cf8a9"},
|
||||
{file = "greenlet-0.4.16-cp35-cp35m-win32.whl", hash = "sha256:1000038ba0ea9032948e2156a9c15f5686f36945e8f9906e6b8db49f358e7b52"},
|
||||
{file = "greenlet-0.4.16-cp35-cp35m-win_amd64.whl", hash = "sha256:1b805231bfb7b2900a16638c3c8b45c694334c811f84463e52451e00c9412691"},
|
||||
{file = "greenlet-0.4.16-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e5db19d4a7d41bbeb3dd89b49fc1bc7e6e515b51bbf32589c618655a0ebe0bf0"},
|
||||
{file = "greenlet-0.4.16-cp36-cp36m-win32.whl", hash = "sha256:eac2a3f659d5f41d6bbfb6a97733bc7800ea5e906dc873732e00cebb98cec9e4"},
|
||||
{file = "greenlet-0.4.16-cp36-cp36m-win_amd64.whl", hash = "sha256:7eed31f4efc8356e200568ba05ad645525f1fbd8674f1e5be61a493e715e3873"},
|
||||
{file = "greenlet-0.4.16-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:682328aa576ec393c1872615bcb877cf32d800d4a2f150e1a5dc7e56644010b1"},
|
||||
{file = "greenlet-0.4.16-cp37-cp37m-win32.whl", hash = "sha256:3a35e33902b2e6079949feed7a2dafa5ac6f019da97bd255842bb22de3c11bf5"},
|
||||
{file = "greenlet-0.4.16-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b2a984bbfc543d144d88caad6cc7ff4a71be77102014bd617bd88cfb038727"},
|
||||
{file = "greenlet-0.4.16-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:d83c1d38658b0f81c282b41238092ed89d8f93c6e342224ab73fb39e16848721"},
|
||||
{file = "greenlet-0.4.16-cp38-cp38-win32.whl", hash = "sha256:e695ac8c3efe124d998230b219eb51afb6ef10524a50b3c45109c4b77a8a3a92"},
|
||||
{file = "greenlet-0.4.16-cp38-cp38-win_amd64.whl", hash = "sha256:133ba06bad4e5f2f8bf6a0ac434e0fd686df749a86b3478903b92ec3a9c0c90b"},
|
||||
{file = "greenlet-0.4.16.tar.gz", hash = "sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
|
||||
@@ -401,10 +553,41 @@ markupsafe = [
|
||||
{file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
|
||||
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
|
||||
]
|
||||
msgpack = [
|
||||
{file = "msgpack-1.0.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:cec8bf10981ed70998d98431cd814db0ecf3384e6b113366e7f36af71a0fca08"},
|
||||
{file = "msgpack-1.0.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aa5c057eab4f40ec47ea6f5a9825846be2ff6bf34102c560bad5cad5a677c5be"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:4233b7f86c1208190c78a525cd3828ca1623359ef48f78a6fea4b91bb995775a"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b3758dfd3423e358bbb18a7cccd1c74228dffa7a697e5be6cb9535de625c0dbf"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:25b3bc3190f3d9d965b818123b7752c5dfb953f0d774b454fd206c18fe384fb8"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-win32.whl", hash = "sha256:e7bbdd8e2b277b77782f3ce34734b0dfde6cbe94ddb74de8d733d603c7f9e2b1"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5dba6d074fac9b24f29aaf1d2d032306c27f04187651511257e7831733293ec2"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:908944e3f038bca67fcfedb7845c4a257c7749bf9818632586b53bcf06ba4b97"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:db685187a415f51d6b937257474ca72199f393dad89534ebbdd7d7a3b000080e"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ea41c9219c597f1d2bf6b374d951d310d58684b5de9dc4bd2976db9e1e22c140"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-win32.whl", hash = "sha256:e35b051077fc2f3ce12e7c6a34cf309680c63a842db3a0616ea6ed25ad20d272"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5bea44181fc8e18eed1d0cd76e355073f00ce232ff9653a0ae88cb7d9e643322"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c901e8058dd6653307906c5f157f26ed09eb94a850dddd989621098d347926ab"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:271b489499a43af001a2e42f42d876bb98ccaa7e20512ff37ca78c8e12e68f84"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7a22c965588baeb07242cb561b63f309db27a07382825fc98aecaf0827c1538e"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-win32.whl", hash = "sha256:002a0d813e1f7b60da599bdf969e632074f9eec1b96cbed8fb0973a63160a408"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:39c54fdebf5fa4dda733369012c59e7d085ebdfe35b6cf648f09d16708f1be5d"},
|
||||
{file = "msgpack-1.0.0.tar.gz", hash = "sha256:9534d5cc480d4aff720233411a1f765be90885750b07df772380b34c10ecb5c0"},
|
||||
]
|
||||
neovim = [
|
||||
{file = "neovim-0.3.1.tar.gz", hash = "sha256:a6a0e7a5b4433bf4e6ddcbc5c5ff44170be7d84259d002b8e8d8fb4ee78af60f"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
|
||||
{file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"},
|
||||
]
|
||||
passlib = [
|
||||
{file = "passlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177"},
|
||||
{file = "passlib-1.7.2.tar.gz", hash = "sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8"},
|
||||
]
|
||||
pathspec = [
|
||||
{file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"},
|
||||
{file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"},
|
||||
]
|
||||
pathtools = [
|
||||
{file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"},
|
||||
]
|
||||
@@ -412,10 +595,17 @@ pycparser = [
|
||||
{file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
|
||||
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
|
||||
]
|
||||
pynvim = [
|
||||
{file = "pynvim-0.4.1.tar.gz", hash = "sha256:55e918d664654cfa1c9889d3dbe7c63e9f338df5d49471663f78d54c85e84c58"},
|
||||
]
|
||||
pyopenssl = [
|
||||
{file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
|
||||
{file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
|
||||
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
||||
]
|
||||
python-dateutil = [
|
||||
{file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
|
||||
{file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
|
||||
@@ -424,6 +614,29 @@ pytz = [
|
||||
{file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"},
|
||||
{file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"},
|
||||
]
|
||||
regex = [
|
||||
{file = "regex-2020.6.8-cp27-cp27m-win32.whl", hash = "sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c"},
|
||||
{file = "regex-2020.6.8-cp27-cp27m-win_amd64.whl", hash = "sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-win32.whl", hash = "sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-win_amd64.whl", hash = "sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-win32.whl", hash = "sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-win_amd64.whl", hash = "sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-manylinux1_i686.whl", hash = "sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-win32.whl", hash = "sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-win_amd64.whl", hash = "sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5"},
|
||||
{file = "regex-2020.6.8.tar.gz", hash = "sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
|
||||
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
|
||||
@@ -432,6 +645,33 @@ six = [
|
||||
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
|
||||
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
|
||||
]
|
||||
toml = [
|
||||
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
|
||||
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
|
||||
]
|
||||
typed-ast = [
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
|
||||
{file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
|
||||
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
|
||||
]
|
||||
urllib3 = [
|
||||
{file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"},
|
||||
{file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "jellyfin-accounts"
|
||||
version = "0.2.0"
|
||||
version = "0.2.6"
|
||||
readme = "README.md"
|
||||
description = "A simple account management system for Jellyfin"
|
||||
authors = ["Harvey Tindall <harveyltindall@gmail.com>"]
|
||||
@@ -21,17 +21,19 @@ classifiers = [
|
||||
python = "^3.6"
|
||||
pyopenssl = "^19.1.0"
|
||||
flask = "^1.1.2"
|
||||
flask-httpauth = "^3.3.0"
|
||||
flask-httpauth = ">= 3.3.0"
|
||||
requests = "^2.23.0"
|
||||
itsdangerous = "^1.1.0"
|
||||
passlib = "^1.7.2"
|
||||
pytz = "^2020.1"
|
||||
python-dateutil = "^2.8.1"
|
||||
watchdog = "^0.10.2"
|
||||
configparser = "^5.0.0"
|
||||
waitress = "^1.4.3"
|
||||
packaging = "^20.4"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
neovim = "^0.3.1"
|
||||
black = "^19.10b0"
|
||||
|
||||
|
||||
[tool.poetry.scripts]
|
||||
|
||||
Reference in New Issue
Block a user