mirror of
https://github.com/hrfee/jellyfin-accounts.git
synced 2026-10-07 07:37:08 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da3832e3a | ||
|
|
dcdb02f9db | ||
|
|
4be88c4670 | ||
|
|
4fb99d1724 | ||
|
|
adef32ef89 | ||
|
|
ade935da4e | ||
|
|
81bb2520ad | ||
|
|
acad3b1853 | ||
|
|
d1cd83f5ff | ||
|
|
061fdd65bb | ||
|
|
34e58f5cb2 | ||
|
|
fe12b7c4be | ||
|
|
ac500e14cd | ||
|
|
ac60cc37da | ||
|
|
46751e3743 | ||
|
|
0bb54d1c45 | ||
|
|
8e94f04d5a | ||
|
|
eb8e04d5a2 | ||
|
|
52a11c3905 | ||
|
|
52f9b5c963 | ||
|
|
55d26b541a | ||
|
|
4606415a38 | ||
|
|
00ba11940a | ||
|
|
9532f24e7a | ||
|
|
95e5d8fb3d | ||
|
|
3f1b2ad4a8 | ||
|
|
b775e36171 | ||
|
|
68a459023c | ||
|
|
09bbe8fddf | ||
|
|
99c34d7916 | ||
|
|
db1b707ec1 | ||
|
|
4809331502 | ||
|
|
24045034c8 | ||
|
|
079dff8d9f | ||
|
|
b943bd1f27 | ||
|
|
267a47087c | ||
|
|
4d6872dc17 | ||
|
|
4372c9d12f |
+7
-2
@@ -4,11 +4,16 @@ MANIFEST.in
|
||||
dist/
|
||||
build/
|
||||
test.txt
|
||||
data/node_modules/
|
||||
node_modules/
|
||||
jellyfin_accounts/data/config-default.ini
|
||||
*.egg-info/
|
||||
pw-reset/
|
||||
jfa/
|
||||
colors.txt
|
||||
theme.css
|
||||
data/static/bootstrap-jf.css
|
||||
jellyfin_accounts/__pycache__/
|
||||
old/
|
||||
.jf-accounts/
|
||||
requirements.txt
|
||||
package-lock.json
|
||||
video/
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# 
|
||||
# 
|
||||
|
||||
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
|
||||
* Provides a web interface for creating/sending invites
|
||||
* Sends out emails when a user requests a password reset
|
||||
* Uses a basic python jellyfin API client for communication with the server.
|
||||
* Uses [Flask](https://github.com/pallets/flask), [HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth), [itsdangerous](https://github.com/pallets/itsdangerous), and [Waitress](https://github.com/Pylons/waitress)
|
||||
* Frontend uses [Bootstrap](https://getbootstrap.com), [jQuery](https://jquery.com) and [jQuery-serialize-object](https://github.com/macek/jquery-serialize-object)
|
||||
* Frontend uses [Bootstrap](https://v5.getbootstrap.com)
|
||||
* 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/admin.png" width="48%" style="margin-right: 1.5%;" alt="Admin page"></img>
|
||||
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/create.png" width="48%" style="margin-left: 1.5%;" alt="Account creation page"></img>
|
||||
</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)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
[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.3.0
|
||||
device = jf-accounts
|
||||
device_id = jf-accounts-0.3.0
|
||||
|
||||
[ui]
|
||||
; settings related to the ui and program functionality.
|
||||
; choose the look of jellyfin-accounts.
|
||||
theme = Jellyfin (Dark)
|
||||
; 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.
|
||||
; use bootstrap 5 (currently in alpha). this also removes the need for jquery, so the page should load faster.
|
||||
bs5 = false
|
||||
|
||||
[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 =
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 6.7 MiB |
+262
-151
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
__version__ = "0.2"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
import secrets
|
||||
import configparser
|
||||
@@ -11,194 +11,295 @@ 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",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i", "--install", help="attempt to install a system service.", 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"
|
||||
|
||||
|
||||
temp_config = configparser.RawConfigParser()
|
||||
temp_config.read(config_path)
|
||||
|
||||
config = configparser.RawConfigParser()
|
||||
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"]
|
||||
if "bs5" not in config["ui"] or config["ui"]["bs5"] == "":
|
||||
config["ui"]["bs5"] = "false"
|
||||
return config
|
||||
|
||||
|
||||
config = load_config(config_path, data_dir)
|
||||
|
||||
web_log = create_log("waitress")
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
return css
|
||||
data_store = JSONStorage(
|
||||
config["files"]["emails"],
|
||||
config["files"]["invites"],
|
||||
config["files"]["user_template"],
|
||||
config["files"]["user_displayprefs"],
|
||||
config["files"]["user_configuration"],
|
||||
)
|
||||
|
||||
if config.getboolean("ui", "bs5"):
|
||||
css_file = "bs5-jf.css"
|
||||
log.debug("Using Bootstrap 5")
|
||||
else:
|
||||
css_file = "bs4-jf.css"
|
||||
|
||||
|
||||
css = {}
|
||||
css = default_css()
|
||||
if 'custom_css' in config['files']:
|
||||
if config['files']['custom_css'] != '':
|
||||
with open(config_base_path, "r") as f:
|
||||
themes = json.load(f)["ui"]["theme"]
|
||||
|
||||
theme_options = themes["options"]
|
||||
|
||||
if "theme" not in config["ui"] or config["ui"]["theme"] not in theme_options:
|
||||
config["ui"]["theme"] = themes["value"]
|
||||
|
||||
if config.getboolean("ui", "bs5"):
|
||||
num = 5
|
||||
else:
|
||||
num = 4
|
||||
|
||||
current_theme = config["ui"]["theme"]
|
||||
|
||||
if "Bootstrap" in current_theme:
|
||||
css_file = f"bs{num}.css"
|
||||
elif "Jellyfin" in current_theme:
|
||||
css_file = f"bs{num}-jf.css"
|
||||
elif "Custom" in current_theme and "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'] = ''
|
||||
css_path = Path(config["files"]["custom_css"])
|
||||
shutil.copy(css_path, (local_dir / "static" / css_path.name))
|
||||
log.debug('Loaded custom CSS "{css_path.name}"')
|
||||
css_file = css_path.name
|
||||
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:
|
||||
if args.install:
|
||||
executable = sys.argv[0]
|
||||
print(f'Assuming executable path "{executable}".')
|
||||
options = ["systemd"]
|
||||
for i, opt in enumerate(options):
|
||||
print(f"{i+1}: {opt}")
|
||||
success = False
|
||||
while not success:
|
||||
try:
|
||||
method = options[int(input(">: ")) - 1]
|
||||
success = True
|
||||
except IndexError:
|
||||
pass
|
||||
if method == "systemd":
|
||||
with open(local_dir / "services" / "jf-accounts.service", "r") as f:
|
||||
data = f.read()
|
||||
data = data.replace("{executable}", executable)
|
||||
service_path = str(Path("jf-accounts.service").resolve())
|
||||
with open(service_path, "w") as f:
|
||||
f.write(data)
|
||||
print(f"service written to the current directory\n({service_path}).")
|
||||
print("Place this in the appropriate directory, and reload daemons.")
|
||||
elif 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 +308,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))
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
{
|
||||
"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."
|
||||
},
|
||||
"theme": {
|
||||
"name": "Look",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "select",
|
||||
"options": [
|
||||
"Bootstrap (Light)",
|
||||
"Jellyfin (Dark)",
|
||||
"Custom CSS"
|
||||
],
|
||||
"value": "Jellyfin (Dark)",
|
||||
"description": "Choose the look of jellyfin-accounts."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"bs5": {
|
||||
"name": "Use Bootstrap 5",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Use Bootstrap 5 (currently in alpha). This also removes the need for jQuery, so the page should load faster."
|
||||
}
|
||||
},
|
||||
"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": true,
|
||||
"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 =
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=A basic account management system for Jellyfin.
|
||||
|
||||
[Service]
|
||||
ExecStart={executable}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,451 +0,0 @@
|
||||
function parseInvite(invite, empty = false) {
|
||||
if (empty === true) {
|
||||
return ["None", "", "1"]
|
||||
} else {
|
||||
var i = ["", "", "0", invite['email']];
|
||||
i[0] = invite['code'];
|
||||
if (invite['hours'] == 0) {
|
||||
i[1] = invite['minutes'] + 'm';
|
||||
} else if (invite['minutes'] == 0) {
|
||||
i[1] = invite['hours'] + 'h';
|
||||
} else {
|
||||
i[1] = invite['hours'] + 'h ' + invite['minutes'] + 'm';
|
||||
}
|
||||
i[1] = "Expires in " + i[1] + " ";
|
||||
return i
|
||||
}
|
||||
}
|
||||
function addItem(invite) {
|
||||
var links = document.getElementById('invites');
|
||||
var listItem = document.createElement('li');
|
||||
listItem.id = invite[0]
|
||||
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
|
||||
var listCode = document.createElement('div');
|
||||
listCode.classList.add('d-flex', 'align-items-center', 'text-monospace');
|
||||
var codeLink = document.createElement('a');
|
||||
codeLink.setAttribute('style', 'margin-right: 2%;');
|
||||
codeLink.appendChild(document.createTextNode(invite[0].replace(/-/g, '‑')));
|
||||
listCode.appendChild(codeLink);
|
||||
listItem.appendChild(listCode);
|
||||
var listRight = document.createElement('div');
|
||||
listText = document.createElement('span');
|
||||
listText.id = invite[0] + '_expiry'
|
||||
listText.appendChild(document.createTextNode(invite[1]));
|
||||
listRight.appendChild(listText);
|
||||
if (invite[2] == 0) {
|
||||
var inviteCode = window.location.href + 'invite/' + invite[0];
|
||||
codeLink.href = inviteCode;
|
||||
// listCode.appendChild(document.createTextNode(" "));
|
||||
var codeCopy = document.createElement('i');
|
||||
codeCopy.onclick = function(){toClipboard(inviteCode)};
|
||||
codeCopy.classList.add('fa', 'fa-clipboard');
|
||||
listCode.appendChild(codeCopy);
|
||||
if (typeof(invite[3]) != 'undefined') {
|
||||
var sentTo = document.createElement('span');
|
||||
sentTo.setAttribute('style', 'color: grey; margin-left: 2%; font-style: italic; font-size: 75%;');
|
||||
if (invite[3].includes('Failed to send to')) {
|
||||
sentTo.appendChild(document.createTextNode(invite[3]));
|
||||
} else {
|
||||
sentTo.appendChild(document.createTextNode('Sent to ' + invite[3]));
|
||||
}
|
||||
listCode.appendChild(sentTo);
|
||||
};
|
||||
var listDelete = document.createElement('button');
|
||||
listDelete.onclick = function(){deleteInvite(invite[0])};
|
||||
listDelete.classList.add('btn', 'btn-outline-danger');
|
||||
listDelete.appendChild(document.createTextNode('Delete'));
|
||||
listRight.appendChild(listDelete);
|
||||
};
|
||||
listItem.appendChild(listRight);
|
||||
links.appendChild(listItem);
|
||||
};
|
||||
function updateInvite(invite) {
|
||||
var expiry = document.getElementById(invite[0] + '_expiry');
|
||||
expiry.textContent = invite[1];
|
||||
}
|
||||
function removeInvite(code) {
|
||||
var item = document.getElementById(code);
|
||||
item.parentNode.removeChild(item);
|
||||
}
|
||||
function generateInvites(empty = false) {
|
||||
// document.getElementById('invites').textContent = '';
|
||||
if (empty === false) {
|
||||
$.ajax('/getInvites', {
|
||||
type : 'GET',
|
||||
dataType : 'json',
|
||||
contentType: 'json',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
data: { get_param: 'value' },
|
||||
complete: function(response) {
|
||||
var data = JSON.parse(response['responseText']);
|
||||
if (data['invites'].length == 0) {
|
||||
document.getElementById('invites').textContent = '';
|
||||
addItem(parseInvite([], true));
|
||||
} else {
|
||||
data['invites'].forEach(function(invite) {
|
||||
var match = false;
|
||||
var items = document.getElementById('invites').children;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].id == invite['code']) {
|
||||
match = true;
|
||||
updateInvite(parseInvite(invite));
|
||||
};
|
||||
};
|
||||
if (match == false) {
|
||||
addItem(parseInvite(invite));
|
||||
};
|
||||
});
|
||||
var items = document.getElementById('invites').children;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var exists = false;
|
||||
data['invites'].forEach(function(invite) {
|
||||
if (items[i].id == invite['code']) {
|
||||
exists = true;
|
||||
}
|
||||
});
|
||||
if (exists == false) {
|
||||
removeInvite(items[i].id);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (empty === true) {
|
||||
document.getElementById('invites').textContent = '';
|
||||
addItem(parseInvite([], true));
|
||||
};
|
||||
};
|
||||
function deleteInvite(code) {
|
||||
var send = JSON.stringify({ "code": code });
|
||||
$.ajax('/deleteInvite', {
|
||||
data : send,
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() { generateInvites(); },
|
||||
});
|
||||
};
|
||||
function addOptions(le, sel) {
|
||||
for (v = 0; v <= le; v++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.appendChild(document.createTextNode(v))
|
||||
opt.value = v
|
||||
sel.appendChild(opt)
|
||||
}
|
||||
};
|
||||
function toClipboard(str) {
|
||||
const el = document.createElement('textarea');
|
||||
el.value = str;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
const selected =
|
||||
document.getSelection().rangeCount > 0
|
||||
? document.getSelection().getRangeAt(0)
|
||||
: false;
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
if (selected) {
|
||||
document.getSelection().removeAllRanges();
|
||||
document.getSelection().addRange(selected);
|
||||
}
|
||||
};
|
||||
|
||||
$("form#inviteForm").submit(function() {
|
||||
var button = document.getElementById('generateSubmit');
|
||||
button.disabled = true;
|
||||
button.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
var send_object = $("form#inviteForm").serializeObject();
|
||||
if (document.getElementById('send_to_address') != null) {
|
||||
if (document.getElementById('send_to_address_enabled').checked) {
|
||||
send_object['email'] = document.getElementById('send_to_address').value;
|
||||
}
|
||||
}
|
||||
var send = JSON.stringify(send_object);
|
||||
$.ajax('/generateInvite', {
|
||||
data : send,
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() {
|
||||
button.textContent = 'Generate';
|
||||
button.disabled = false;
|
||||
generateInvites();
|
||||
},
|
||||
|
||||
});
|
||||
return false;
|
||||
});
|
||||
$("form#loginForm").submit(function() {
|
||||
window.token = "";
|
||||
var details = $("form#loginForm").serializeObject();
|
||||
var errorArea = document.getElementById('loginErrorArea');
|
||||
errorArea.textContent = '';
|
||||
var button = document.getElementById('loginSubmit');
|
||||
button.disabled = true;
|
||||
button.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
$.ajax('/getToken', {
|
||||
type : 'GET',
|
||||
dataType : 'json',
|
||||
contentType: 'json',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password']));
|
||||
},
|
||||
data: { get_param: 'value' },
|
||||
complete: function(data) {
|
||||
if (data['status'] == 401) {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Login';
|
||||
var wrongPassword = document.createElement('div');
|
||||
wrongPassword.classList.add('alert', 'alert-danger');
|
||||
wrongPassword.setAttribute('role', 'alert');
|
||||
wrongPassword.appendChild(document.createTextNode('Incorrect username or password.'));
|
||||
errorArea.appendChild(wrongPassword);
|
||||
} else {
|
||||
window.token = JSON.parse(data['responseText'])['token'];
|
||||
generateInvites();
|
||||
var interval = setInterval(function() { generateInvites(); }, 60 * 1000);
|
||||
var hour = document.getElementById('hours');
|
||||
addOptions(24, hour);
|
||||
hour.selected = "0";
|
||||
var minutes = document.getElementById('minutes');
|
||||
addOptions(59, minutes);
|
||||
minutes.selected = "30";
|
||||
$('#login').modal('hide');
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
document.getElementById('openSettings').onclick = function () {
|
||||
$('#settingsMenu').modal('show');
|
||||
}
|
||||
document.getElementById('openDefaultsWizard').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
$.ajax('getUsers', {
|
||||
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 radioList = document.getElementById('defaultUserRadios');
|
||||
radioList.textContent = '';
|
||||
if (document.getElementById('setDefaultUser')) {
|
||||
document.getElementById('setDefaultUser').remove();
|
||||
};
|
||||
var users = data['responseJSON']['users'];
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
var user = users[i]
|
||||
var radio = document.createElement('div');
|
||||
radio.classList.add('radio');
|
||||
if (i == 0) {
|
||||
var checked = 'checked';
|
||||
} else {
|
||||
var checked = '';
|
||||
};
|
||||
radio.innerHTML =
|
||||
'<label><input type="radio" name="defaultRadios" id="default_' +
|
||||
user['name'] + '" style="margin-right: 1rem;"' + checked + '>' +
|
||||
user['name'] + '</label>';
|
||||
radioList.appendChild(radio);
|
||||
}
|
||||
var button = document.getElementById('openDefaultsWizard');
|
||||
button.disabled = false;
|
||||
button.innerHTML = 'Set new account defaults';
|
||||
var submitButton = document.getElementById('storeDefaults');
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Submit';
|
||||
if (submitButton.classList.contains('btn-success')) {
|
||||
submitButton.classList.remove('btn-success');
|
||||
submitButton.classList.add('btn-primary');
|
||||
} else if (submitButton.classList.contains('btn-danger')) {
|
||||
submitButton.classList.remove('btn-danger');
|
||||
submitButton.classList.add('btn-primary');
|
||||
}
|
||||
$('#userDefaults').modal('show');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
document.getElementById('storeDefaults').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
var button = document.getElementById('storeDefaults');
|
||||
var radios = document.getElementsByName('defaultRadios');
|
||||
for (var i = 0; i < radios.length; i++) {
|
||||
if (radios[i].checked) {
|
||||
var data = {'username':radios[i].id.slice(8), 'homescreen':false};
|
||||
if (document.getElementById('storeDefaultHomescreen').checked) {
|
||||
data['homescreen'] = true;
|
||||
}
|
||||
$.ajax('/setDefaults', {
|
||||
data : JSON.stringify(data),
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() {
|
||||
button.textContent = 'Success';
|
||||
if (button.classList.contains('btn-danger')) {
|
||||
button.classList.remove('btn-danger');
|
||||
} else if (button.classList.contains('btn-primary')) {
|
||||
button.classList.remove('btn-primary');
|
||||
};
|
||||
button.classList.add('btn-success');
|
||||
button.disabled = false;
|
||||
setTimeout(function(){$('#userDefaults').modal('hide');}, 1000);
|
||||
},
|
||||
error: function() {
|
||||
button.textContent = 'Failed';
|
||||
button.classList.remove('btn-primary');
|
||||
button.classList.add('btn-danger');
|
||||
setTimeout(function(){
|
||||
var button = document.getElementById('storeDefaults');
|
||||
button.textContent = 'Submit';
|
||||
button.classList.remove('btn-danger');
|
||||
button.classList.add('btn-primary');
|
||||
button.disabled = false;
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
document.getElementById('openUsers').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
$.ajax('/getUsers', {
|
||||
type : 'GET',
|
||||
dataType : 'json',
|
||||
contentType: 'json',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
data: { get_param: 'value' },
|
||||
complete : function(data) {
|
||||
if (data['status'] == 200) {
|
||||
var list = document.getElementById('userList');
|
||||
list.textContent = '';
|
||||
if (document.getElementById('saveUsers')) {
|
||||
document.getElementById('saveUsers').remove();
|
||||
};
|
||||
var users = data['responseJSON']['users'];
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
var user = users[i]
|
||||
var entry = document.createElement('p');
|
||||
entry.id = 'user_' + user['name'];
|
||||
entry.appendChild(document.createTextNode(user['name']));
|
||||
var address = document.createElement('span');
|
||||
address.setAttribute('style', 'margin-left: 2%; margin-right: 2%; color: grey;');
|
||||
address.classList.add('addressText');
|
||||
address.id = 'address_' + user['email'];
|
||||
if (typeof(user['email']) != 'undefined') {
|
||||
address.appendChild(document.createTextNode(user['email']));
|
||||
};
|
||||
var editButton = document.createElement('i');
|
||||
editButton.classList.add('fa', 'fa-edit');
|
||||
editButton.onclick = function() {
|
||||
this.classList.remove('fa', 'fa-edit');
|
||||
var input = document.createElement('input');
|
||||
input.setAttribute('type', 'email');
|
||||
input.setAttribute('style', 'margin-left: 2%; color: grey;');
|
||||
var addressElement = this.parentNode.getElementsByClassName('addressText')[0];
|
||||
if (addressElement.textContent != '') {
|
||||
input.value = addressElement.textContent;
|
||||
} else {
|
||||
input.placeholder = 'Email Address';
|
||||
};
|
||||
this.parentNode.replaceChild(input, addressElement);
|
||||
if (document.getElementById('saveUsers') == null) {
|
||||
var footer = document.getElementById('userFooter')
|
||||
var saveUsers = document.createElement('input');
|
||||
saveUsers.classList.add('btn', 'btn-primary');
|
||||
saveUsers.setAttribute('type', 'button');
|
||||
saveUsers.value = 'Save Changes';
|
||||
saveUsers.id = 'saveUsers';
|
||||
saveUsers.onclick = function() {
|
||||
var send = {}
|
||||
var entries = document.getElementById('userList').children;
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var entry = entries[i];
|
||||
if (typeof(entry.getElementsByTagName('input')[0]) != 'undefined') {
|
||||
var name = entry.id.replace(/user_/g, '')
|
||||
var address = entry.getElementsByTagName('input')[0].value;
|
||||
send[name] = address
|
||||
};
|
||||
};
|
||||
send = JSON.stringify(send);
|
||||
$.ajax('/modifyUsers', {
|
||||
data : send,
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() { $('#users').modal('hide'); },
|
||||
});
|
||||
};
|
||||
footer.appendChild(saveUsers);
|
||||
};
|
||||
};
|
||||
entry.appendChild(address);
|
||||
entry.appendChild(editButton);
|
||||
list.appendChild(entry);
|
||||
};
|
||||
var button = document.getElementById('openUsers');
|
||||
button.disabled = false;
|
||||
button.innerHTML = 'Users <i class="fa fa-user"></i>';
|
||||
$('#users').modal('show');
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
generateInvites(empty = true);
|
||||
$("#login").modal('show');
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
function serializeForm(id) {
|
||||
var form = document.getElementById(id);
|
||||
var formData = {};
|
||||
for (var i = 0; i < form.elements.length; i++) {
|
||||
var el = form.elements[i];
|
||||
if (el.type != 'submit') {
|
||||
var name = el.name;
|
||||
if (name == '') {
|
||||
name = el.id;
|
||||
};
|
||||
switch (el.type) {
|
||||
case 'checkbox':
|
||||
formData[name] = el.checked;
|
||||
break;
|
||||
case 'text':
|
||||
case 'password':
|
||||
case 'select-one':
|
||||
case 'email':
|
||||
formData[name] = el.value;
|
||||
break;
|
||||
};
|
||||
};
|
||||
};
|
||||
return formData;
|
||||
};
|
||||
@@ -1,35 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<title>404</title>
|
||||
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
|
||||
<style>
|
||||
.messageBox {
|
||||
margin: 20%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messageBox">
|
||||
<h1>Page not found.</h1>
|
||||
<p>
|
||||
{{ contactMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
<title>404</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ css_file }}">
|
||||
{% if not bs5 %}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
{% if bs5 %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{% else %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<style>
|
||||
.messageBox {
|
||||
margin: 20%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messageBox">
|
||||
<h1>Page not found.</h1>
|
||||
<p>
|
||||
{{ contactMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,18 +12,22 @@
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
|
||||
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{{ css_file }}">
|
||||
{% if not bs5 %}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
|
||||
<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>
|
||||
{% if bs5 %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{% else %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<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 {
|
||||
@@ -49,17 +53,11 @@
|
||||
margin-top: 5%;
|
||||
color: grey;
|
||||
}
|
||||
.fa-clipboard {
|
||||
color: grey;
|
||||
}
|
||||
.fa-clipboard:hover {
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
<title>Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="modal fade" id="login" tabindex="-1" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
|
||||
<div class="modal fade" id="login" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -82,7 +80,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="settingsMenu" tabindex="-1" role="dialog" aria-labelledby="settings menu" aria-hidden="true">
|
||||
<div class="modal fade" id="settingsMenu" role="dialog" aria-labelledby="settings menu" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -93,25 +91,25 @@
|
||||
</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>
|
||||
</div>
|
||||
<div class="modal fade" id="users" tabindex="-1" role="dialog" aria-labelledby="users" aria-hidden="true">
|
||||
<div class="modal fade" id="users" role="dialog" aria-labelledby="users" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -130,7 +128,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="userDefaults" tabindex="-1" role="dialog" aria-labelledby="users" aria-hidden="true">
|
||||
<div class="modal fade" id="userDefaults" role="dialog" aria-labelledby="users" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -153,6 +151,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="restartModal" role="dialog" aria-labelledby"Restart Warning" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-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
|
||||
@@ -160,13 +174,13 @@
|
||||
<button type="button" class="btn btn-secondary" id="openSettings">
|
||||
Settings <i class="fa fa-cog"></i>
|
||||
</button>
|
||||
<div class="card bg-light mb-3 linkGroup">
|
||||
<div class="card mb-3 linkGroup">
|
||||
<div class="card-header">Current Invites</div>
|
||||
<ul class="list-group list-group-flush" id="invites">
|
||||
</ul>
|
||||
</div>
|
||||
<div class="linkForm">
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Generate Invite</div>
|
||||
<div class="card-body">
|
||||
<form action="#" method="POST" id="inviteForm">
|
||||
@@ -184,16 +198,14 @@
|
||||
<div class="form-group">
|
||||
<label for="send_to_address">Send invite to address</label>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<div class="input-group-text">
|
||||
<input type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
|
||||
</div>
|
||||
<div class="input-group-text">
|
||||
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
|
||||
</div>
|
||||
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" id="generateSubmit" class="btn btn-primary">Generate</button>
|
||||
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">Generate</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,6 +214,7 @@
|
||||
<p>{{ contactMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="serialize.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,745 @@
|
||||
var bsVersion = {{ bsVersion }};
|
||||
|
||||
if (bsVersion == 5) {
|
||||
function createModal(id, find = false) {
|
||||
if (find) {
|
||||
return bootstrap.Modal.getInstance(document.getElementById(id));
|
||||
};
|
||||
return new bootstrap.Modal(document.getElementById(id));
|
||||
};
|
||||
function triggerTooltips() {
|
||||
document.getElementById('settingsMenu').addEventListener('shown.bs.modal', function() {
|
||||
// Hack to ensure anything dependent on checkboxes are disabled if necessary
|
||||
var checkboxes = document.getElementById('settingsMenu').querySelectorAll('input[type="checkbox"]');
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
checkboxes[i].click();
|
||||
checkboxes[i].click();
|
||||
};
|
||||
// Initialize tooltips
|
||||
var to_trigger = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
|
||||
var tooltips = to_trigger.map(function(el) {
|
||||
return new bootstrap.Tooltip(el);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// var loginModal = new bootstrap.Modal(document.getElementById('login'));
|
||||
// var settingsModal = new bootstrap.Modal(document.getElementById('settingsMenu'));
|
||||
// var userDefaultsModal = new bootstrap.Modal(document.getElementById('userDefaults'));
|
||||
// var usersModal = new bootstrap.Modal(document.getElementById('users'));
|
||||
// var restartModal = new bootstrap.Modal(document.getElementById('restartModal'));
|
||||
} else if (bsVersion == 4) {
|
||||
document.getElementById('send_to_address_enabled').classList.remove('form-check-input');
|
||||
function createModal(id, find = false) {
|
||||
return {
|
||||
show : function() {
|
||||
return $('#' + id).modal('show');
|
||||
},
|
||||
hide : function() {
|
||||
return $('#' + id).modal('hide');
|
||||
}
|
||||
};
|
||||
};
|
||||
function triggerTooltips() {
|
||||
$('#settingsMenu').on('shown.bs.modal', function() {
|
||||
var checkboxes = document.getElementById('settingsMenu').querySelectorAll('input[type="checkbox"]');
|
||||
for (var i = 0; i < checkboxes.length; i++) {
|
||||
checkboxes[i].click();
|
||||
checkboxes[i].click();
|
||||
};
|
||||
$("a[data-toggle='tooltip']").each(function (i, obj) {
|
||||
$(obj).tooltip();
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
var loginModal = createModal('login');
|
||||
var settingsModal = createModal('settingsMenu');
|
||||
var userDefaultsModal = createModal('userDefaults');
|
||||
var usersModal = createModal('users');
|
||||
var restartModal = createModal('restartModal');
|
||||
|
||||
function parseInvite(invite, empty = false) {
|
||||
if (empty === true) {
|
||||
return ["None", "", "1"]
|
||||
} else {
|
||||
var i = ["", "", "0", invite['email']];
|
||||
i[0] = invite['code'];
|
||||
if (invite['hours'] == 0) {
|
||||
i[1] = invite['minutes'] + 'm';
|
||||
} else if (invite['minutes'] == 0) {
|
||||
i[1] = invite['hours'] + 'h';
|
||||
} else {
|
||||
i[1] = invite['hours'] + 'h ' + invite['minutes'] + 'm';
|
||||
}
|
||||
i[1] = "Expires in " + i[1] + " ";
|
||||
return i
|
||||
}
|
||||
}
|
||||
function addItem(invite) {
|
||||
var links = document.getElementById('invites');
|
||||
var listItem = document.createElement('li');
|
||||
listItem.id = invite[0]
|
||||
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
|
||||
var listCode = document.createElement('div');
|
||||
listCode.classList.add('d-flex', 'align-items-center', 'font-monospace');
|
||||
var codeLink = document.createElement('a');
|
||||
codeLink.setAttribute('style', 'margin-right: 0.5rem;');
|
||||
codeLink.appendChild(document.createTextNode(invite[0].replace(/-/g, '‑')));
|
||||
listCode.appendChild(codeLink);
|
||||
listItem.appendChild(listCode);
|
||||
var listRight = document.createElement('div');
|
||||
listText = document.createElement('span');
|
||||
listText.id = invite[0] + '_expiry'
|
||||
listText.appendChild(document.createTextNode(invite[1]));
|
||||
listRight.appendChild(listText);
|
||||
if (invite[2] == 0) {
|
||||
var inviteCode = window.location.href.replace('#', '') + 'invite/' + invite[0];
|
||||
codeLink.href = inviteCode;
|
||||
// listCode.appendChild(document.createTextNode(" "));
|
||||
var codeCopy = document.createElement('i');
|
||||
codeCopy.onclick = function(){toClipboard(inviteCode)};
|
||||
codeCopy.classList.add('fa', 'fa-clipboard', 'icon-button');
|
||||
listCode.appendChild(codeCopy);
|
||||
if (typeof(invite[3]) != 'undefined') {
|
||||
var sentTo = document.createElement('span');
|
||||
sentTo.setAttribute('style', 'color: grey; margin-left: 2%; font-style: italic; font-size: 75%;');
|
||||
if (invite[3].includes('Failed to send to')) {
|
||||
sentTo.appendChild(document.createTextNode(invite[3]));
|
||||
} else {
|
||||
sentTo.appendChild(document.createTextNode('Sent to ' + invite[3]));
|
||||
}
|
||||
listCode.appendChild(sentTo);
|
||||
};
|
||||
var listDelete = document.createElement('button');
|
||||
listDelete.onclick = function(){deleteInvite(invite[0])};
|
||||
listDelete.classList.add('btn', 'btn-outline-danger');
|
||||
listDelete.appendChild(document.createTextNode('Delete'));
|
||||
listRight.appendChild(listDelete);
|
||||
};
|
||||
listItem.appendChild(listRight);
|
||||
links.appendChild(listItem);
|
||||
};
|
||||
function updateInvite(invite) {
|
||||
var expiry = document.getElementById(invite[0] + '_expiry');
|
||||
expiry.textContent = invite[1];
|
||||
}
|
||||
function removeInvite(code) {
|
||||
var item = document.getElementById(code);
|
||||
item.parentNode.removeChild(item);
|
||||
}
|
||||
function generateInvites(empty = false) {
|
||||
// document.getElementById('invites').textContent = '';
|
||||
if (empty === false) {
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("GET", "/getInvites", true);
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.responseType = 'json';
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
var data = this.response;
|
||||
if (data['invites'].length == 0) {
|
||||
document.getElementById('invites').textContent = '';
|
||||
addItem(parseInvite([], true));
|
||||
} else {
|
||||
data['invites'].forEach(function(invite) {
|
||||
var match = false;
|
||||
var items = document.getElementById('invites').children;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].id == invite['code']) {
|
||||
match = true;
|
||||
updateInvite(parseInvite(invite));
|
||||
};
|
||||
};
|
||||
if (match == false) {
|
||||
addItem(parseInvite(invite));
|
||||
};
|
||||
});
|
||||
var items = document.getElementById('invites').children;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var exists = false;
|
||||
data['invites'].forEach(function(invite) {
|
||||
if (items[i].id == invite['code']) {
|
||||
exists = true;
|
||||
}
|
||||
});
|
||||
if (exists == false) {
|
||||
removeInvite(items[i].id);
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
req.send();
|
||||
} else if (empty === true) {
|
||||
document.getElementById('invites').textContent = '';
|
||||
addItem(parseInvite([], true));
|
||||
};
|
||||
};
|
||||
function deleteInvite(code) {
|
||||
var send = JSON.stringify({ "code": code });
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", "/deleteInvite", true);
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
generateInvites();
|
||||
};
|
||||
};
|
||||
req.send(send);
|
||||
};
|
||||
function addOptions(le, sel) {
|
||||
for (v = 0; v <= le; v++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.appendChild(document.createTextNode(v));
|
||||
opt.value = v;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
};
|
||||
function toClipboard(str) {
|
||||
const el = document.createElement('textarea');
|
||||
el.value = str;
|
||||
el.setAttribute('readOnly', '');
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
const selected =
|
||||
document.getSelection().rangeCount > 0
|
||||
? document.getSelection().getRangeAt(0)
|
||||
: false;
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
if (selected) {
|
||||
document.getSelection().removeAllRanges();
|
||||
document.getSelection().addRange(selected);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('inviteForm').onsubmit = function() {
|
||||
var button = document.getElementById('generateSubmit');
|
||||
button.disabled = true;
|
||||
button.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
send_object = serializeForm('inviteForm');
|
||||
console.log(send_object);
|
||||
if (document.getElementById('send_to_address') != null) {
|
||||
if (send_object['send_to_address_enabled']) {
|
||||
send_object['email'] = send_object['send_to_address'];
|
||||
delete send_object['send_to_address'];
|
||||
delete send_object['send_to_address_enabled'];
|
||||
}
|
||||
}
|
||||
var send = JSON.stringify(send_object);
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", "/generateInvite", true);
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
button.textContent = 'Generate';
|
||||
button.disabled = false;
|
||||
generateInvites();
|
||||
};
|
||||
};
|
||||
req.send(send);
|
||||
return false;
|
||||
};
|
||||
document.getElementById('loginForm').onsubmit = function() {
|
||||
window.token = "";
|
||||
var details = serializeForm('loginForm');
|
||||
var errorArea = document.getElementById('loginErrorArea');
|
||||
errorArea.textContent = '';
|
||||
var button = document.getElementById('loginSubmit');
|
||||
button.disabled = true;
|
||||
button.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
var req = new XMLHttpRequest();
|
||||
req.responseType = 'json';
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 401) {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Login';
|
||||
var wrongPassword = document.createElement('div');
|
||||
wrongPassword.classList.add('alert', 'alert-danger');
|
||||
wrongPassword.setAttribute('role', 'alert');
|
||||
wrongPassword.appendChild(document.createTextNode('Incorrect username or password.'));
|
||||
errorArea.appendChild(wrongPassword);
|
||||
} else {
|
||||
var data = this.response;
|
||||
window.token = data['token'];
|
||||
generateInvites();
|
||||
var interval = setInterval(function() { generateInvites(); }, 60 * 1000);
|
||||
var hour = document.getElementById('hours');
|
||||
addOptions(24, hour);
|
||||
hour.selected = "0";
|
||||
var minutes = document.getElementById('minutes');
|
||||
addOptions(59, minutes);
|
||||
minutes.selected = "30";
|
||||
loginModal.hide();
|
||||
};
|
||||
};
|
||||
};
|
||||
req.open("GET", "/getToken", true);
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password']));
|
||||
req.send();
|
||||
return false;
|
||||
};
|
||||
document.getElementById('openDefaultsWizard').onclick = function() {
|
||||
this.disabled = true
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
var req = new XMLHttpRequest();
|
||||
req.responseType = 'json';
|
||||
req.open("GET", "/getUsers", true);
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200) {
|
||||
var users = req.response['users'];
|
||||
var radioList = document.getElementById('defaultUserRadios');
|
||||
radioList.textContent = '';
|
||||
if (document.getElementById('setDefaultUser')) {
|
||||
document.getElementById('setDefaultUser').remove();
|
||||
};
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
var user = users[i]
|
||||
var radio = document.createElement('div');
|
||||
radio.classList.add('radio');
|
||||
if (i == 0) {
|
||||
var checked = 'checked';
|
||||
} else {
|
||||
var checked = '';
|
||||
};
|
||||
radio.innerHTML =
|
||||
'<label><input type="radio" name="defaultRadios" id="default_' +
|
||||
user['name'] + '" style="margin-right: 1rem;"' + checked + '>' +
|
||||
user['name'] + '</label>';
|
||||
radioList.appendChild(radio);
|
||||
}
|
||||
var button = document.getElementById('openDefaultsWizard');
|
||||
button.disabled = false;
|
||||
button.innerHTML = 'Set new account defaults';
|
||||
var submitButton = document.getElementById('storeDefaults');
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Submit';
|
||||
if (submitButton.classList.contains('btn-success')) {
|
||||
submitButton.classList.remove('btn-success');
|
||||
submitButton.classList.add('btn-primary');
|
||||
} else if (submitButton.classList.contains('btn-danger')) {
|
||||
submitButton.classList.remove('btn-danger');
|
||||
submitButton.classList.add('btn-primary');
|
||||
};
|
||||
settingsModal.hide();
|
||||
userDefaultsModal.show();
|
||||
};
|
||||
};
|
||||
};
|
||||
req.send();
|
||||
};
|
||||
document.getElementById('storeDefaults').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
var button = document.getElementById('storeDefaults');
|
||||
var radios = document.getElementsByName('defaultRadios');
|
||||
for (var i = 0; i < radios.length; i++) {
|
||||
if (radios[i].checked) {
|
||||
var data = {'username':radios[i].id.slice(8), 'homescreen':false};
|
||||
if (document.getElementById('storeDefaultHomescreen').checked) {
|
||||
data['homescreen'] = true;
|
||||
}
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", "/setDefaults", true);
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200 || this.status == 204) {
|
||||
button.textContent = 'Success';
|
||||
if (button.classList.contains('btn-danger')) {
|
||||
button.classList.remove('btn-danger');
|
||||
} else if (button.classList.contains('btn-primary')) {
|
||||
button.classList.remove('btn-primary');
|
||||
};
|
||||
button.classList.add('btn-success');
|
||||
button.disabled = false;
|
||||
setTimeout(function(){$('#userDefaults').modal('hide');}, 1000);
|
||||
} else {
|
||||
button.textContent = 'Failed';
|
||||
button.classList.remove('btn-primary');
|
||||
button.classList.add('btn-danger');
|
||||
setTimeout(function(){
|
||||
var button = document.getElementById('storeDefaults');
|
||||
button.textContent = 'Submit';
|
||||
button.classList.remove('btn-danger');
|
||||
button.classList.add('btn-primary');
|
||||
button.disabled = false;
|
||||
}, 1000);
|
||||
};
|
||||
};
|
||||
};
|
||||
req.send(JSON.stringify(data));
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// $.ajax('/setDefaults', {
|
||||
// data : JSON.stringify(data),
|
||||
// contentType : 'application/json',
|
||||
// type : 'POST',
|
||||
// xhrFields : {
|
||||
// withCredentials: true
|
||||
// },
|
||||
// beforeSend : function (xhr) {
|
||||
// xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
// },
|
||||
// success: function() {
|
||||
// button.textContent = 'Success';
|
||||
// if (button.classList.contains('btn-danger')) {
|
||||
// button.classList.remove('btn-danger');
|
||||
// } else if (button.classList.contains('btn-primary')) {
|
||||
// button.classList.remove('btn-primary');
|
||||
// };
|
||||
// button.classList.add('btn-success');
|
||||
// button.disabled = false;
|
||||
// setTimeout(function(){$('#userDefaults').modal('hide');}, 1000);
|
||||
// },
|
||||
// error: function() {
|
||||
// button.textContent = 'Failed';
|
||||
// button.classList.remove('btn-primary');
|
||||
// button.classList.add('btn-danger');
|
||||
// setTimeout(function(){
|
||||
// var button = document.getElementById('storeDefaults');
|
||||
// button.textContent = 'Submit';
|
||||
// button.classList.remove('btn-danger');
|
||||
// button.classList.add('btn-primary');
|
||||
// button.disabled = false;
|
||||
// }, 1000);
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
document.getElementById('openUsers').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("GET", "/getUsers", true);
|
||||
req.responseType = 'json';
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200) {
|
||||
var list = document.getElementById('userList');
|
||||
list.textContent = '';
|
||||
if (document.getElementById('saveUsers')) {
|
||||
document.getElementById('saveUsers').remove();
|
||||
};
|
||||
var users = req.response['users'];
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
var user = users[i]
|
||||
var entry = document.createElement('div');
|
||||
entry.classList.add('form-group', 'list-group-item', 'py-1');
|
||||
entry.id = 'user_' + user['name'];
|
||||
var label = document.createElement('label');
|
||||
label.classList.add('d-inline-block');
|
||||
label.setAttribute('for', 'address_' + user['email']);
|
||||
label.appendChild(document.createTextNode(user['name']));
|
||||
entry.appendChild(label);
|
||||
var address = document.createElement('input');
|
||||
address.setAttribute('type', 'email');
|
||||
address.readOnly = true;
|
||||
address.classList.add('form-control-plaintext', 'text-muted', 'd-inline-block');
|
||||
//address.setAttribute('style', 'margin-left: 2%; margin-right: 2%; color: grey;');
|
||||
address.classList.add('addressText');
|
||||
address.id = 'address_' + user['email'];
|
||||
if (typeof(user['email']) != 'undefined') {
|
||||
address.value = user['email'];
|
||||
address.setAttribute('style', 'width: auto; margin-left: 2%;');
|
||||
};
|
||||
var editButton = document.createElement('i');
|
||||
editButton.classList.add('fa', 'fa-edit', 'd-inline-block', 'icon-button');
|
||||
editButton.setAttribute('style', 'margin-left: 2%;');
|
||||
editButton.onclick = function() {
|
||||
this.classList.remove('fa', 'fa-edit');
|
||||
// var input = document.createElement('input');
|
||||
// input.setAttribute('type', 'email');
|
||||
// input.classList.add('email-input');
|
||||
//var addressElement = this.parentNode.getElementsByClassName('addressText')[0];
|
||||
var addressElement = this.parentNode.getElementsByClassName('form-control-plaintext')[0];
|
||||
addressElement.classList.remove('form-control-plaintext', 'text-muted');
|
||||
addressElement.classList.add('form-control');
|
||||
addressElement.readOnly = false;
|
||||
if (addressElement.value == '') {
|
||||
// input.value = addressElement.textContent;
|
||||
// } else {
|
||||
addressElement.placeholder = 'Email Address';
|
||||
address.setAttribute('style', 'width: auto; margin-left: 2%;');
|
||||
};
|
||||
// this.parentNode.replaceChild(input, addressElement);
|
||||
if (document.getElementById('saveUsers') == null) {
|
||||
var footer = document.getElementById('userFooter')
|
||||
var saveUsers = document.createElement('input');
|
||||
saveUsers.classList.add('btn', 'btn-primary');
|
||||
saveUsers.setAttribute('type', 'button');
|
||||
saveUsers.value = 'Save Changes';
|
||||
saveUsers.id = 'saveUsers';
|
||||
saveUsers.onclick = function() {
|
||||
var send = {}
|
||||
var entries = document.getElementById('userList').children;
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var entry = entries[i];
|
||||
if (typeof(entry.getElementsByTagName('input')[0]) != 'undefined') {
|
||||
var name = entry.id.replace(/user_/g, '')
|
||||
var address = entry.getElementsByTagName('input')[0].value;
|
||||
send[name] = address
|
||||
};
|
||||
};
|
||||
send = JSON.stringify(send);
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", "/modifyUsers", true);
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200 || this.status == 204) {
|
||||
usersModal.hide();
|
||||
};
|
||||
};
|
||||
};
|
||||
req.send(send);
|
||||
};
|
||||
footer.appendChild(saveUsers);
|
||||
};
|
||||
};
|
||||
entry.appendChild(editButton);
|
||||
entry.appendChild(address);
|
||||
list.appendChild(entry);
|
||||
};
|
||||
var button = document.getElementById('openUsers');
|
||||
button.disabled = false;
|
||||
button.innerHTML = 'Users <i class="fa fa-user"></i>';
|
||||
settingsModal.hide();
|
||||
usersModal.show();
|
||||
};
|
||||
}
|
||||
};
|
||||
req.send();
|
||||
};
|
||||
|
||||
generateInvites(empty = true);
|
||||
loginModal.show()
|
||||
|
||||
var config = {};
|
||||
var modifiedConfig = {};
|
||||
|
||||
document.getElementById('openSettings').onclick = function () {
|
||||
restart_setting_changed = false;
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("GET", "/getConfig", true);
|
||||
req.responseType = 'json';
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4 && this.status == 200) {
|
||||
var settingsList = document.getElementById('settingsList');
|
||||
settingsList.textContent = '';
|
||||
config = this.response;
|
||||
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);
|
||||
};
|
||||
};
|
||||
};
|
||||
req.send();
|
||||
settingsModal.show();
|
||||
};
|
||||
|
||||
triggerTooltips();
|
||||
//
|
||||
// $('#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);
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", "/modifyConfig", true);
|
||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200 || this.status == 204) {
|
||||
createModal(modalId, true).hide();
|
||||
if (modalId != 'settingsMenu') {
|
||||
settingsModal.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);
|
||||
// },
|
||||
};
|
||||
req.send(send);
|
||||
};
|
||||
|
||||
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');};
|
||||
settingsModal.hide();
|
||||
restartModal.show();
|
||||
} else if (settings_changed) {
|
||||
sendConfig('settingsMenu');
|
||||
} else {
|
||||
settingsModal.hide();
|
||||
};
|
||||
};
|
||||
@@ -12,15 +12,21 @@
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
|
||||
<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>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" type="text/css" href="{{ css_file }}">
|
||||
{% if not bs5 %}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
{% if bs5 %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{% else %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<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 {
|
||||
@@ -61,24 +67,26 @@
|
||||
<p class="contactBox">{{ contactMessage }}</p>
|
||||
<div class="container" id="container">
|
||||
<div class="row" id="cardContainer">
|
||||
<div class="col-sm" id="accountForm">
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="col-sm">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Details</div>
|
||||
<div class="card-body">
|
||||
<form action="#" method="POST">
|
||||
<form action="#" method="POST" id="accountForm">
|
||||
<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>
|
||||
</div>
|
||||
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox">
|
||||
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox" style="margin-top: 1rem;">
|
||||
<button type="submit" class="btn btn-outline-primary" id="submitButton">
|
||||
<span id="createAccount">Create Account</span>
|
||||
</button>
|
||||
@@ -89,7 +97,7 @@
|
||||
</div>
|
||||
{% if validate %}
|
||||
<div class="col-sm" id="requirementBox">
|
||||
<div class="card bg-light mb-3 requirementBox">
|
||||
<div class="card mb-3 requirementBox">
|
||||
<div class="card-header">Password Requirements</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
@@ -106,7 +114,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="serialize.js"></script>
|
||||
<script>
|
||||
{% if bs5 %}
|
||||
var bsVersion = 5;
|
||||
{% else %}
|
||||
var bsVersion = 4;
|
||||
{% endif %}
|
||||
if (bsVersion == 5) {
|
||||
var successBox = new bootstrap.Modal(document.getElementById('successBox'));
|
||||
} else if (bsVersion == 4) {
|
||||
var successBox = {
|
||||
show : function() {
|
||||
return $('#successBox').modal('show');
|
||||
},
|
||||
hide : function() {
|
||||
return $('#successBox').modal('hide');
|
||||
}
|
||||
};
|
||||
};
|
||||
var code = window.location.href.split('/').pop();
|
||||
function toggleSpinner () {
|
||||
var submitButton = document.getElementById('submitButton');
|
||||
@@ -129,22 +155,24 @@
|
||||
}
|
||||
submitButton.replaceChild(newSpan, oldSpan);
|
||||
};
|
||||
$("form").submit(function() {
|
||||
document.getElementById('accountForm').onsubmit = function() {
|
||||
toggleSpinner();
|
||||
var send = $("form").serializeObject();
|
||||
var send = serializeForm('accountForm');
|
||||
send['code'] = code;
|
||||
{% if not username %}
|
||||
send['email'] = send['username'];
|
||||
{% endif %}
|
||||
send = JSON.stringify(send);
|
||||
$.ajax('/newUser', {
|
||||
data : send,
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
crossDomain : true,
|
||||
complete : function(response){
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", "/newUser", true);
|
||||
req.responseType = 'json';
|
||||
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
req.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
toggleSpinner();
|
||||
var data = response['responseJSON'];
|
||||
var data = this.response;
|
||||
if ('error' in data) {
|
||||
var text = document.createTextNode(data['error']);
|
||||
// <div class="alert alert-danger" id="errorBox"></div>
|
||||
var error = document.createElement('button');
|
||||
error.classList.add('btn', 'btn-outline-danger');
|
||||
error.setAttribute('disabled', '');
|
||||
@@ -172,13 +200,14 @@
|
||||
};
|
||||
};
|
||||
if (valid == true) {
|
||||
$('#successBox').modal('show');
|
||||
successBox.show();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
};
|
||||
req.send(send);
|
||||
return false;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Invalid Code</title>
|
||||
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
|
||||
<style>
|
||||
.messageBox {
|
||||
margin: 20%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messageBox">
|
||||
<h1>Invalid Code.</h1>
|
||||
<p>The above code is either incorrect, or has expired.</p>
|
||||
<p>{{ contactMessage }}</p>
|
||||
</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Invalid Code</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link rel="stylesheet" type="text/css" href="{{ css_file }}">
|
||||
{% if not bs5 %}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
{% if bs5 %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{% else %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<style>
|
||||
.messageBox {
|
||||
margin: 20%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messageBox">
|
||||
<h1>Invalid Code.</h1>
|
||||
<p>The above code is either incorrect, or has expired.</p>
|
||||
<p>{{ contactMessage }}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
|
||||
|
||||
+106
-91
@@ -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:
|
||||
|
||||
@@ -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
|
||||
+122
-73
@@ -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:
|
||||
|
||||
+42
-37
@@ -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()
|
||||
|
||||
+25
-35
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+62
-47
@@ -1,70 +1,85 @@
|
||||
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_file, 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",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
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",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
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:
|
||||
if "admin.js" in path:
|
||||
if config.getboolean("ui", "bs5"):
|
||||
bsVersion = 5
|
||||
else:
|
||||
bsVersion = 4
|
||||
return render_template("admin.js", bsVersion=bsVersion)
|
||||
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",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
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",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
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",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
)
|
||||
|
||||
+204
-125
@@ -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,293 @@ 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]:
|
||||
temp_config[section][item] = data[section][item]
|
||||
data[section][item] = True
|
||||
log.debug(f"{section}/{item} modified")
|
||||
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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=A basic account management system for Jellyfin.
|
||||
|
||||
[Service]
|
||||
ExecStart=/home/hrfee/.cache/pypoetry/virtualenvs/jellyfin-accounts-r2jcKHws-py3.8/bin/jf-accounts
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Generated
+2569
File diff suppressed because it is too large
Load Diff
Generated
+260
-20
@@ -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"},
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "jellyfin-accounts"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
readme = "README.md"
|
||||
description = "A simple account management system for Jellyfin"
|
||||
authors = ["Harvey Tindall <harveyltindall@gmail.com>"]
|
||||
@@ -9,7 +9,7 @@ homepage = "https://github.com/hrfee/jellyfin-accounts"
|
||||
repository = "https://github.com/hrfee/jellyfin-accounts"
|
||||
keywords = ["jellyfin", "jf-accounts"]
|
||||
include = ["jellyfin_accounts/data/*"]
|
||||
exclude = ["images/*"]
|
||||
exclude = ["images/*", "scss/*"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
certifi==2020.4.5.2 \
|
||||
--hash=sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc \
|
||||
--hash=sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1
|
||||
cffi==1.14.0 \
|
||||
--hash=sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384 \
|
||||
--hash=sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30 \
|
||||
--hash=sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c \
|
||||
--hash=sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78 \
|
||||
--hash=sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793 \
|
||||
--hash=sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e \
|
||||
--hash=sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a \
|
||||
--hash=sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff \
|
||||
--hash=sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f \
|
||||
--hash=sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa \
|
||||
--hash=sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5 \
|
||||
--hash=sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4 \
|
||||
--hash=sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d \
|
||||
--hash=sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc \
|
||||
--hash=sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac \
|
||||
--hash=sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f \
|
||||
--hash=sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b \
|
||||
--hash=sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3 \
|
||||
--hash=sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66 \
|
||||
--hash=sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0 \
|
||||
--hash=sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f \
|
||||
--hash=sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26 \
|
||||
--hash=sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd \
|
||||
--hash=sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55 \
|
||||
--hash=sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2 \
|
||||
--hash=sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8 \
|
||||
--hash=sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b \
|
||||
--hash=sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6
|
||||
chardet==3.0.4 \
|
||||
--hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \
|
||||
--hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae
|
||||
click==7.1.2 \
|
||||
--hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc \
|
||||
--hash=sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a
|
||||
cryptography==2.9.2 \
|
||||
--hash=sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e \
|
||||
--hash=sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b \
|
||||
--hash=sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365 \
|
||||
--hash=sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0 \
|
||||
--hash=sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55 \
|
||||
--hash=sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270 \
|
||||
--hash=sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf \
|
||||
--hash=sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d \
|
||||
--hash=sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785 \
|
||||
--hash=sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b \
|
||||
--hash=sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae \
|
||||
--hash=sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b \
|
||||
--hash=sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6 \
|
||||
--hash=sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3 \
|
||||
--hash=sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b \
|
||||
--hash=sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e \
|
||||
--hash=sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0 \
|
||||
--hash=sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5 \
|
||||
--hash=sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229
|
||||
flask==1.1.2 \
|
||||
--hash=sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557 \
|
||||
--hash=sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060
|
||||
flask-httpauth==3.3.0 \
|
||||
--hash=sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9 \
|
||||
--hash=sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72
|
||||
idna==2.9 \
|
||||
--hash=sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa \
|
||||
--hash=sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb
|
||||
itsdangerous==1.1.0 \
|
||||
--hash=sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749 \
|
||||
--hash=sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19
|
||||
jinja2==2.11.2 \
|
||||
--hash=sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035 \
|
||||
--hash=sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0
|
||||
markupsafe==1.1.1 \
|
||||
--hash=sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161 \
|
||||
--hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7 \
|
||||
--hash=sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183 \
|
||||
--hash=sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b \
|
||||
--hash=sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e \
|
||||
--hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \
|
||||
--hash=sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1 \
|
||||
--hash=sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5 \
|
||||
--hash=sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1 \
|
||||
--hash=sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735 \
|
||||
--hash=sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21 \
|
||||
--hash=sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235 \
|
||||
--hash=sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b \
|
||||
--hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \
|
||||
--hash=sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905 \
|
||||
--hash=sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1 \
|
||||
--hash=sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d \
|
||||
--hash=sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff \
|
||||
--hash=sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473 \
|
||||
--hash=sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e \
|
||||
--hash=sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66 \
|
||||
--hash=sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5 \
|
||||
--hash=sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d \
|
||||
--hash=sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e \
|
||||
--hash=sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6 \
|
||||
--hash=sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2 \
|
||||
--hash=sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c \
|
||||
--hash=sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15 \
|
||||
--hash=sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2 \
|
||||
--hash=sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42 \
|
||||
--hash=sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b \
|
||||
--hash=sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be \
|
||||
--hash=sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b
|
||||
packaging==20.4 \
|
||||
--hash=sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181 \
|
||||
--hash=sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8
|
||||
passlib==1.7.2 \
|
||||
--hash=sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177 \
|
||||
--hash=sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8
|
||||
pathtools==0.1.2 \
|
||||
--hash=sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0
|
||||
pycparser==2.20 \
|
||||
--hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705 \
|
||||
--hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0
|
||||
pyopenssl==19.1.0 \
|
||||
--hash=sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504 \
|
||||
--hash=sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507
|
||||
pyparsing==2.4.7 \
|
||||
--hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b \
|
||||
--hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1
|
||||
python-dateutil==2.8.1 \
|
||||
--hash=sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c \
|
||||
--hash=sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a
|
||||
pytz==2020.1 \
|
||||
--hash=sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed \
|
||||
--hash=sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048
|
||||
requests==2.23.0 \
|
||||
--hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \
|
||||
--hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6
|
||||
six==1.15.0 \
|
||||
--hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced \
|
||||
--hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259
|
||||
urllib3==1.25.9 \
|
||||
--hash=sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115 \
|
||||
--hash=sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527
|
||||
waitress==1.4.4 \
|
||||
--hash=sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db \
|
||||
--hash=sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261
|
||||
watchdog==0.10.2 \
|
||||
--hash=sha256:c560efb643faed5ef28784b2245cf8874f939569717a4a12826a173ac644456b
|
||||
werkzeug==1.0.1 \
|
||||
--hash=sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43 \
|
||||
--hash=sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c
|
||||
@@ -0,0 +1,16 @@
|
||||
## SCSS
|
||||
|
||||
* `bs<4/5>-jf.scss` contains the source for the customizations to bootstrap. To customize the UI, you can make modifications to this file and then compile it.
|
||||
|
||||
**Note**: For BS5, it is assumed that bootstrap is installed in `../../node_modules/bootstrap` relative to itself.
|
||||
For BS4, it assumes that bootstrap is installed in `../../node_modules/bootstrap4` relative to itself (`npm install bootstrap4@npm:bootstrap`).
|
||||
* Compilation requires a sass compiler of your choice, and `postcss-cli`, `autoprefixer` + `clean-css-cli` from npm.
|
||||
* If you're using `sassc`, run `./compile.sh bs<4/5>-jf.scss` in this directory. This will create a .css file, and minified .css file.
|
||||
* For `node-sass`, replace the `sassc` line in `compile.sh` with
|
||||
```
|
||||
node-sass --output-style expanded --precision 6 $1 $css_file
|
||||
```
|
||||
and run as above.
|
||||
* If you're building from source, copy the minified css to `<jf-accounts git directory>/jellyfin_accounts/data/static/bs<4/5>-jf.css`.
|
||||
* If you're just customizing your install, set `custom_css` in your config as the path to your minified css and change the `theme` option to `Custom CSS`.
|
||||
|
||||
+9548
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,136 @@
|
||||
$jf-blue: rgb(0, 164, 220);
|
||||
$jf-blue-hover: rgba(0, 164, 220, 0.2);
|
||||
$jf-blue-focus: rgb(12, 176, 232);
|
||||
$jf-blue-light: #4bb3dd;
|
||||
|
||||
$jf-red: rgb(204, 0, 0);
|
||||
$jf-red-light: #e12026;
|
||||
$jf-yellower: #ffc107;
|
||||
$jf-yellow: #e1b222;
|
||||
$jf-orange: #ff870f;
|
||||
$jf-green: #6fbd45;
|
||||
$jf-green-dark: #008040;
|
||||
|
||||
|
||||
$jf-black: #101010; // 16 16 16
|
||||
$jf-gray-90: #202020; // 32 32 32
|
||||
$jf-gray-80: #242424; // jf-card 36 36 36
|
||||
$jf-gray-70: #292929; // jf-input 41 41 41
|
||||
$jf-gray-60: #303030; // jf-button 48 48 48
|
||||
$jf-gray-50: #383838; // jf-button-focus 56 56 56
|
||||
$jf-text-bold: rgba(255, 255, 255, 0.87);
|
||||
$jf-text-primary: rgba(255, 255, 255, 0.8);
|
||||
$jf-text-secondary: rgb(153, 153, 153);
|
||||
|
||||
$primary: $jf-blue;
|
||||
$secondary: $jf-gray-50;
|
||||
$success: $jf-green-dark;
|
||||
$danger: $jf-red-light;
|
||||
$light: $jf-text-primary;
|
||||
$dark: $jf-gray-90;
|
||||
$info: $jf-yellow;
|
||||
$warning: $jf-yellower;
|
||||
|
||||
|
||||
|
||||
$enable-gradients: false;
|
||||
$enable-shadows: false;
|
||||
|
||||
$enable-rounded: false;
|
||||
$body-bg: $jf-black;
|
||||
$body-color: $jf-text-primary;
|
||||
$border-color: $jf-gray-60;
|
||||
$component-active-color: $jf-text-bold;
|
||||
$component-active-bg: $jf-blue-focus;
|
||||
$text-muted: $jf-text-secondary;
|
||||
$link-color: $jf-blue-focus;
|
||||
$btn-link-disabled-color: $jf-text-secondary;
|
||||
$input-bg: $jf-gray-90;
|
||||
$input-color: $jf-text-primary;
|
||||
$input-focus-bg: $jf-gray-60;
|
||||
$input-focus-border-color: $jf-blue-focus;
|
||||
$input-disabled-bg: $jf-gray-70;
|
||||
input:disabled {
|
||||
color: $text-muted;
|
||||
}
|
||||
$input-border-color: $jf-gray-60;
|
||||
$input-placeholder-color: $text-muted;
|
||||
|
||||
$form-check-input-bg: $jf-gray-60;
|
||||
$form-check-input-border: $jf-gray-50;
|
||||
$form-check-input-checked-color: $jf-blue-focus;
|
||||
$form-check-input-checked-bg-color: $jf-blue-hover;
|
||||
|
||||
$input-group-addon-bg: $input-bg;
|
||||
|
||||
$form-select-disabled-color: $jf-text-secondary;
|
||||
$form-select-disabled-bg: $input-disabled-bg;
|
||||
$form-select-indicator-color: $jf-gray-50;
|
||||
|
||||
$card-bg: $jf-gray-80;
|
||||
$card-border-color: null;
|
||||
|
||||
$tooltip-color: $jf-text-bold;
|
||||
$tooltip-bg: $jf-gray-50;
|
||||
|
||||
$modal-content-bg: $jf-gray-80;
|
||||
$modal-content-border-color: $jf-gray-50;
|
||||
$modal-header-border-color: null;
|
||||
$modal-footer-border-color: null;
|
||||
|
||||
$list-group-bg: $card-bg;
|
||||
$list-group-border-color: $jf-gray-50;
|
||||
$list-group-hover-bg: $jf-blue-hover;
|
||||
$list-group-active-bg: $jf-blue-focus;
|
||||
$list-group-action-color: $jf-text-primary;
|
||||
$list-group-action-hover-color: $jf-text-bold;
|
||||
$list-group-action-active-color: $jf-text-bold;
|
||||
$list-group-action-active-bg: $jf-blue-focus;
|
||||
|
||||
// idk why but i had to put these above and below the import
|
||||
.list-group-item-danger {
|
||||
color: $jf-text-bold;
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
.list-group-item-success {
|
||||
color: $jf-text-bold;
|
||||
background-color: $success;
|
||||
}
|
||||
|
||||
@import "../../node_modules/bootstrap4/scss/bootstrap";
|
||||
|
||||
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: $jf-text-secondary;
|
||||
}
|
||||
|
||||
.close:hover, .close:active {
|
||||
color: $jf-text-primary;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.text-bright {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.list-group-item-danger {
|
||||
color: $jf-text-bold;
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
.list-group-item-success {
|
||||
color: $jf-text-bold;
|
||||
background-color: $success;
|
||||
}
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
css_file=$(echo $1 | sed 's/scss/css/g')
|
||||
min_file=$(echo $1 | sed 's/scss/min.css/g')
|
||||
sassc -t expanded -p 6 $1 $css_file
|
||||
echo "Compiled."
|
||||
postcss $css_file --replace --use autoprefixer
|
||||
echo "Prefixed."
|
||||
echo "Written to $css_file."
|
||||
cleancss --level 1 --format breakWith=lf --source-map --source-map-inline-sources --output $min_file $css_file
|
||||
echo "Minified version written to $min_file."
|
||||
+9438
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,136 @@
|
||||
$jf-blue: rgb(0, 164, 220);
|
||||
$jf-blue-hover: rgba(0, 164, 220, 0.2);
|
||||
$jf-blue-focus: rgb(12, 176, 232);
|
||||
$jf-blue-light: #4bb3dd;
|
||||
|
||||
$jf-red: rgb(204, 0, 0);
|
||||
$jf-red-light: #e12026;
|
||||
$jf-yellower: #ffc107;
|
||||
$jf-yellow: #e1b222;
|
||||
$jf-orange: #ff870f;
|
||||
$jf-green: #6fbd45;
|
||||
$jf-green-dark: #008040;
|
||||
|
||||
|
||||
$jf-black: #101010; // 16 16 16
|
||||
$jf-gray-90: #202020; // 32 32 32
|
||||
$jf-gray-80: #242424; // jf-card 36 36 36
|
||||
$jf-gray-70: #292929; // jf-input 41 41 41
|
||||
$jf-gray-60: #303030; // jf-button 48 48 48
|
||||
$jf-gray-50: #383838; // jf-button-focus 56 56 56
|
||||
$jf-text-bold: rgba(255, 255, 255, 0.87);
|
||||
$jf-text-primary: rgba(255, 255, 255, 0.8);
|
||||
$jf-text-secondary: rgb(153, 153, 153);
|
||||
|
||||
$primary: $jf-blue;
|
||||
$secondary: $jf-gray-50;
|
||||
$success: $jf-green-dark;
|
||||
$danger: $jf-red-light;
|
||||
$light: $jf-text-primary;
|
||||
$dark: $jf-gray-90;
|
||||
$info: $jf-yellow;
|
||||
$warning: $jf-yellower;
|
||||
|
||||
|
||||
|
||||
$enable-gradients: false;
|
||||
$enable-shadows: false;
|
||||
|
||||
$enable-rounded: false;
|
||||
$body-bg: $jf-black;
|
||||
$body-color: $jf-text-primary;
|
||||
$border-color: $jf-gray-60;
|
||||
$component-active-color: $jf-text-bold;
|
||||
$component-active-bg: $jf-blue-focus;
|
||||
$text-muted: $jf-text-secondary;
|
||||
$link-color: $jf-blue-focus;
|
||||
$btn-link-disabled-color: $jf-text-secondary;
|
||||
$input-bg: $jf-gray-90;
|
||||
$input-color: $jf-text-primary;
|
||||
$input-focus-bg: $jf-gray-60;
|
||||
$input-focus-border-color: $jf-blue-focus;
|
||||
$input-disabled-bg: $jf-gray-70;
|
||||
input:disabled {
|
||||
color: $text-muted;
|
||||
}
|
||||
$input-border-color: $jf-gray-60;
|
||||
$input-placeholder-color: $text-muted;
|
||||
|
||||
$form-check-input-bg: $jf-gray-60;
|
||||
$form-check-input-border: $jf-gray-50;
|
||||
$form-check-input-checked-color: $jf-blue-focus;
|
||||
$form-check-input-checked-bg-color: $jf-blue-hover;
|
||||
|
||||
$input-group-addon-bg: $input-bg;
|
||||
|
||||
$form-select-disabled-color: $jf-text-secondary;
|
||||
$form-select-disabled-bg: $input-disabled-bg;
|
||||
$form-select-indicator-color: $jf-gray-50;
|
||||
|
||||
$card-bg: $jf-gray-80;
|
||||
$card-border-color: null;
|
||||
|
||||
$tooltip-color: $jf-text-bold;
|
||||
$tooltip-bg: $jf-gray-50;
|
||||
|
||||
$modal-content-bg: $jf-gray-80;
|
||||
$modal-content-border-color: $jf-gray-50;
|
||||
$modal-header-border-color: null;
|
||||
$modal-footer-border-color: null;
|
||||
|
||||
$list-group-bg: $card-bg;
|
||||
$list-group-border-color: $jf-gray-50;
|
||||
$list-group-hover-bg: $jf-blue-hover;
|
||||
$list-group-active-bg: $jf-blue-focus;
|
||||
$list-group-action-color: $jf-text-primary;
|
||||
$list-group-action-hover-color: $jf-text-bold;
|
||||
$list-group-action-active-color: $jf-text-bold;
|
||||
$list-group-action-active-bg: $jf-blue-focus;
|
||||
|
||||
// idk why but i had to put these above and below the import
|
||||
.list-group-item-danger {
|
||||
color: $jf-text-bold;
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
.list-group-item-success {
|
||||
color: $jf-text-bold;
|
||||
background-color: $success;
|
||||
}
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/bootstrap";
|
||||
|
||||
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: $jf-text-secondary;
|
||||
}
|
||||
|
||||
.close:hover, .close:active {
|
||||
color: $jf-text-primary;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.text-bright {
|
||||
color: $jf-text-bold;
|
||||
}
|
||||
|
||||
.list-group-item-danger {
|
||||
color: $jf-text-bold;
|
||||
background-color: $danger;
|
||||
}
|
||||
|
||||
.list-group-item-success {
|
||||
color: $jf-text-bold;
|
||||
background-color: $success;
|
||||
}
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
css_file=$(echo $1 | sed 's/scss/css/g')
|
||||
min_file=$(echo $1 | sed 's/scss/min.css/g')
|
||||
sassc -t expanded -p 6 $1 $css_file
|
||||
echo "Compiled."
|
||||
postcss $css_file --replace --use autoprefixer
|
||||
echo "Prefixed."
|
||||
echo "Written to $css_file."
|
||||
cleancss --level 1 --format breakWith=lf --source-map --source-map-inline-sources --output $min_file $css_file
|
||||
echo "Minified version written to $min_file."
|
||||
Reference in New Issue
Block a user