42 Commits

Author SHA1 Message Date
27169e4e0d Added dropdown menu for invites, multi-use invites, bump to 0.3.6
Dropdown menu includes time created, and for multi-use invites,
remaining uses, as well as a list of usernames created using the code.
2020-07-10 16:15:17 +01:00
db3b992857 refactor admin.js, add initial ui elements for multi-use invites
multi-use invites will have a set limit of how many times they can be
used. They can also be set to have no limit. An additional menu is
planned for multi use invites to see when they have been used, and by
who.
2020-07-09 23:05:01 +01:00
89c132e92e bump to 0.3.5 2020-07-09 20:34:31 +01:00
7bda2f4141 Fix UI error when 'send to address' option disabled 2020-07-09 20:33:14 +01:00
71f05f2348 Replace jquery ajax in setup.js 2020-07-07 20:10:27 +01:00
94e69ad090 Small CSS tweaks, add days input 2020-07-07 15:30:16 +01:00
a3d3d97b3b Added theme toggle to Admin page
The admin can switch between the two default themes without a page
reload, with a nice animation (on small screens). Preference is stored
as a cookie, so the default theme setting will still apply to others.
2020-07-06 20:53:14 +01:00
781306f1ef Automation of CSS compilation, fixed .gitignore build issue
The grabbing of dependencies and compilation of SCSS can now simply be
done with a:

poetry run task compile-css

before a:

poetry build

When building from source. The issue where the .gitignore had to be
removed before building has been fixed, too.
2020-07-06 15:04:28 +01:00
a62eab9565 bump to 0.3.2 due to packaging errors 2020-07-05 21:50:52 +01:00
a2a2abc7f2 fix mime type for admin.js 2020-07-05 21:39:58 +01:00
fa0527c6a7 Remove all css
Removed css because I don't want the "Languages" section to show 90%
CSS. Build instructions will be updated with how to build CSS yourself.
2020-07-05 16:42:39 +01:00
b33922059c remove extra css 2020-07-05 16:39:48 +01:00
9da3832e3a Merge pull request #28 from hrfee/bs5
Add themes, Bootstrap 5 support, bump to 0.3.0
2020-07-05 16:34:32 +01:00
dcdb02f9db Merge branch 'master' into bs5 2020-07-05 16:34:05 +01:00
4be88c4670 modify and add new images to readme 2020-07-05 16:25:55 +01:00
4fb99d1724 Cleanup, add theme options
Add theme options to settings to choose between light and dark.
2020-07-05 15:09:42 +01:00
adef32ef89 BS4 by default, BS5 optional
Bootstrap 4 w/ jQuery is used by default unless bs5 is enabled in
settings/ui. bs4 also now has a jellyfin-style look.
2020-07-05 14:38:07 +01:00
ade935da4e jellyfin-lookin theme, changes from master, bump to 0.3.0
Now uses a customized bootstrap that looks something like Jellyfin. Some
small ui changes were needed. This be overridden by downloading bs5's css and using the custom_css
option if you don't like it. sass file is included for your own modification. Changes made to master have been added also.
2020-07-04 22:17:49 +01:00
81bb2520ad Starting work on jellyfin-esque css 2020-07-04 14:00:17 +01:00
acad3b1853 Fix admin, convert invite form, change readme 2020-07-03 22:22:47 +01:00
d1cd83f5ff no more jQuery on admin page
Functions well, but a few changes might be necessary visually.
2020-07-03 21:07:04 +01:00
061fdd65bb fixed image on readme 2020-07-02 18:46:48 +01:00
34e58f5cb2 Add systemd service
Add -i, which currently only creates a systemd service file in the
current directory for the user to install themselves.
2020-07-02 18:44:42 +01:00
fe12b7c4be Change to bs5 (not functional) 2020-07-02 17:59:15 +01:00
ac500e14cd Merge pull request #26 from hrfee/dynamic-settings
Add live reloading to some options, email fix
2020-06-30 21:28:00 +01:00
ac60cc37da Add live reloading to some options, email fix
live reloading was intended for previous release, but needed some
tweaking. Settings that still require a restart are marked with an R.
Fixed issue where default values weren't being filled in on reload of
config that broke emails if settings were changed at all.
2020-06-30 21:24:07 +01:00
46751e3743 Merge pull request #25 from hrfee/dynamic-settings
Full settings added to web UI
2020-06-30 20:02:41 +01:00
0bb54d1c45 Remove config from readme, bump to 0.2.5 2020-06-30 19:58:06 +01:00
8e94f04d5a Add tooltips; cleanup 2020-06-30 18:57:04 +01:00
eb8e04d5a2 Added settings menu to UI
Currently all setting changes require a restart to apply, so there's a
bit of commented out code that i implemented before i realized.
Still needs tooltips for each setting.
2020-06-30 16:17:40 +01:00
52a11c3905 auth fix 2020-06-29 23:24:54 +01:00
52f9b5c963 added new /getConfig 2020-06-29 23:23:43 +01:00
55d26b541a dynamically generate default config on first run 2020-06-29 23:06:58 +01:00
4606415a38 added config-base file and config.ini generator 2020-06-29 22:05:40 +01:00
00ba11940a add donation link 2020-06-29 13:03:09 +01:00
9532f24e7a Merge pull request #24 from Spiritreader/master
Suggestion: Improve margins for admin and invite form templates
2020-06-29 12:42:39 +01:00
Sam
95e5d8fb3d adjust margins in form template 2020-06-29 02:25:55 +02:00
Sam
3f1b2ad4a8 adjust margins in admin template 2020-06-29 02:25:18 +02:00
b775e36171 update readme 2020-06-29 00:39:30 +01:00
68a459023c Add option to use email address as username
Added option email/no_username to disable username input on form, and
instead use the provided email address as the username. Also added
missing 'packaging' dep from pevious update.
2020-06-29 00:35:51 +01:00
09bbe8fddf New version number 2020-06-27 15:52:03 +01:00
99c34d7916 Merge pull request #21 from hrfee/10.6.0-compatability
10.6.0 compatibility
2020-06-27 15:48:30 +01:00
35 changed files with 3877 additions and 993 deletions

11
.gitignore vendored
View File

@@ -4,11 +4,18 @@ 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__/
jellyfin_accounts/data/static/*.css
old/
.jf-accounts/
requirements.txt
video/
scss/bs5/*.css*
scss/bs4/*.css*

141
README.md
View File

@@ -1,11 +1,11 @@
# ![jellyfin-accounts](https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/jellyfin-accounts-banner-wide.svg)
# ![jellyfin-accounts](https://raw.githubusercontent.com/hrfee/jellyfin-accounts/bs5/images/jellyfin-accounts-banner-wide.svg)
A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin).
* 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">
@@ -13,7 +13,7 @@ A basic account management system for [Jellyfin](https://github.com/jellyfin/jel
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/jfa.gif" width="48%" style="margin-right: 1.5%;" alt="Admin page"></img>
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/master/images/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>
@@ -36,6 +36,7 @@ A basic account management system for [Jellyfin](https://github.com/jellyfin/jel
* pytz
* python-dateutil
* watchdog
* packaging
```
### Install
@@ -45,7 +46,7 @@ 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]
@@ -64,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)

119
config-default.ini Normal file
View File

@@ -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.2
device = jf-accounts
device_id = jf-accounts-0.3.2
[ui]
; settings related to the ui and program functionality.
; default appearance for all users.
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

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
__version__ = "0.2.1"
__version__ = "0.3.6"
import secrets
import configparser
@@ -11,7 +11,7 @@ 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")
@@ -35,6 +35,9 @@ parser.add_argument(
),
action="store_true",
)
parser.add_argument(
"-i", "--install", help="attempt to install a system service.", action="store_true"
)
args, leftovers = parser.parse_known_args()
@@ -44,15 +47,20 @@ else:
data_dir = Path.home() / ".jf-accounts"
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 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))
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:
@@ -61,14 +69,15 @@ if data_dir.exists() is False or (data_dir / "config.ini").exists() is False:
else:
config_path = data_dir / "config.ini"
config = configparser.RawConfigParser()
config.read(config_path)
temp_config = configparser.RawConfigParser()
temp_config.read(config_path)
def create_log(name):
log = logging.getLogger(name)
handler = logging.StreamHandler(sys.stdout)
if config.getboolean("ui", "debug"):
if temp_config.getboolean("ui", "debug"):
log.setLevel(logging.DEBUG)
handler.setLevel(logging.DEBUG)
else:
@@ -83,6 +92,63 @@ def create_log(name):
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")
@@ -95,29 +161,22 @@ 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))
try:
with open(config["files"]["invites"], "r") as f:
temp_invites = json.load(f)
if "invites" in temp_invites:
new_invites = {}
log.info("Converting invites.json to new format, temporary.")
for el in temp_invites["invites"]:
i = {"valid_till": el["valid_till"]}
if "email" in el:
i["email"] = el["email"]
new_invites[el["code"]] = i
with open(config["files"]["invites"], "w") as f:
f.write(json.dumps(new_invites, indent=4, default=str))
except FileNotFoundError:
pass
data_store = JSONStorage(
@@ -128,71 +187,82 @@ data_store = JSONStorage(
config["files"]["user_configuration"],
)
def default_css():
css = {}
css[
"href"
] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
css[
"integrity"
] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
css["crossorigin"] = "anonymous"
return css
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"]:
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(f'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.'
)
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
@@ -284,6 +354,7 @@ def main():
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["JSON_SORT_KEYS"] = False
from waitress import serve

View File

@@ -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": "Default Look",
"required": false,
"requires_restart": true,
"type": "select",
"options": [
"Bootstrap (Light)",
"Jellyfin (Dark)",
"Custom CSS"
],
"value": "Jellyfin (Dark)",
"description": "Default appearance for all users."
},
"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."
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
[Unit]
Description=A basic account management system for Jellyfin.
[Service]
ExecStart={executable}
[Install]
WantedBy=default.target

View File

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

View File

@@ -0,0 +1,26 @@
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':
case 'number':
formData[name] = el.value;
break;
};
};
};
return formData;
};

View File

@@ -13,6 +13,7 @@ for (var i = 0; i < authRadios.length; i++) {
checkAuthRadio();
});
};
function checkEmailRadio() {
document.getElementById('emailNextButton').href = '#page-5';
document.getElementById('valBackButton').href = '#page-7';
@@ -35,6 +36,7 @@ for (var i = 0; i < emailRadios.length; i++) {
checkEmailRadio();
});
};
function checkSSL() {
var label = document.getElementById('emailSSL_TLSLabel');
if (document.getElementById('emailSSL_TLS').checked) {
@@ -101,16 +103,15 @@ document.getElementById('jfTestButton').onclick = function() {
jfData['jfHost'] = document.getElementById('jfHost').value;
jfData['jfUser'] = document.getElementById('jfUser').value;
jfData['jfPassword'] = document.getElementById('jfPassword').value;
$.ajax('/testJF', {
type : 'POST',
dataType : 'json',
contentType : 'application/json',
data : JSON.stringify(jfData),
complete: function(response) {
var req = new XMLHttpRequest();
req.open("POST", "/testJF", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
testButton.disabled = false;
testButton.className = '';
var success = response['responseJSON']['success'];
if (success == true) {
if (this.response['success'] == true) {
testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success';
nextButton.classList.remove('disabled');
@@ -118,9 +119,10 @@ document.getElementById('jfTestButton').onclick = function() {
} else {
testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed';
}
}
});
};
};
};
req.send(JSON.stringify(jfData));
};
document.getElementById('submitButton').onclick = function() {
@@ -158,7 +160,7 @@ document.getElementById('submitButton').onclick = function() {
if (document.getElementById('emailDisabledRadio').checked) {
config['password_resets']['enabled'] = 'false';
config['invite_emails']['enabled'] = 'false';
} else {
} else {
if (document.getElementById('emailSMTPRadio').checked) {
if (document.getElementById('emailSSL_TLS').checked) {
config['smtp']['encryption'] = 'ssl_tls';
@@ -211,24 +213,24 @@ document.getElementById('submitButton').onclick = function() {
config['password_validation']['number'] = document.getElementById('valNumber').value;
config['password_validation']['special'] = document.getElementById('valSpecial').value;
} else {
config['password_validation']['enabled'] = 'false';
config['password_validation']['enabled'] = 'false';
};
// Page 9: Messages
config['ui']['contact_message'] = document.getElementById('msgContact').value;
config['ui']['help_message'] = document.getElementById('msgHelp').value;
config['ui']['success_message'] = document.getElementById('msgSuccess').value;
console.log(config);
$.ajax('/modifyConfig', {
type : 'POST',
dataType : 'json',
contentType : 'application/json',
data : JSON.stringify(config),
complete: function(response) {
// Send it
var req = new XMLHttpRequest();
req.open("POST", "/modifyConfig", true);
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
submitButton.disabled = false;
submitButton.className = '';
submitButton.classList.add('btn', 'btn-success');
submitButton.textContent = 'Success';
}
});
};
};
req.send(JSON.stringify(config));
};

View File

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

View File

@@ -12,18 +12,57 @@
<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>
<script>
function getCookie(cname) {
var name = cname + "=";
var decodedCookie = decodeURIComponent(document.cookie);
var ca = decodedCookie.split(';');
for(var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
};
{% if bs5 %}
const bsVersion = 5;
{% else %}
const bsVersion = 4;
{% endif %}
console.log('create');
var css = document.createElement('link');
css.setAttribute('rel', 'stylesheet');
css.setAttribute('type', 'text/css');
var cssCookie = getCookie("css");
if (cssCookie.includes('bs' + bsVersion)) {
console.log('href');
css.setAttribute('href', cssCookie);
} else {
console.log('href');
css.setAttribute('href', '{{ css_file }}');
};
console.log('append');
document.head.appendChild(css);
</script>
{% 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 +88,43 @@
margin-top: 5%;
color: grey;
}
.fa-clipboard {
color: grey;
.circle {
/*margin-left: 1rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
z-index: 5000;*/
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
}
.fa-clipboard:hover {
color: black;
.smooth-transition {
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeincubic */
}
.rotated {
transform: rotate(180deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
.not-rotated {
transform: rotate(0deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
</style>
<title>Admin</title>
</head>
<body>
<div class="modal fade" id="login" tabindex="-1" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
<body class="smooth-transition">
<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 +147,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 +158,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 +195,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,47 +218,100 @@
</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
</h1>
<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="btn-group" role="group" id="headerButtons">
<button type="button" class="btn btn-primary" id="openSettings">
Settings <i class="fa fa-cog"></i>
</button>
</div>
<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">
<div class="form-group">
<label for="hours">Hours</label>
<select class="form-control" id="hours" name="hours">
</select>
</div>
<div class="form-group">
<label for="minutes">Minutes</label>
<select class="form-control" id="minutes" name="minutes">
</select>
</div>
{% if email_enabled %}
<div class="form-group">
<label for="send_to_address">Send invite to address</label>
<div class="input-group">
<div class="input-group-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>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
<form action="#" method="POST" id="inviteForm" class="container">
<div class="row align-items-start">
<div class="col">
<div class="form-group">
<label for="days">Days</label>
<select class="form-control form-select" id="days" name="days">
</select>
</div>
<div class="form-group">
<label for="hours">Hours</label>
<select class="form-control form-select" id="hours" name="hours">
</select>
</div>
<div class="form-group">
<label for="minutes">Minutes</label>
<select class="form-control form-select" id="minutes" name="minutes">
</select>
</div>
</div>
{% endif %}
<button type="submit" id="generateSubmit" class="btn btn-primary">Generate</button>
<div class="col">
<div class="form-group">
<label for="multiUseCount">
Multiple uses
</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('multiUseCount').disabled = !this.checked; document.getElementById('noUseLimit').disabled = !this.checked" aria-label="Checkbox to allow choice of invite usage limit" name="multiple-uses" id="multiUseEnabled">
</div>
<input type="number" class="form-control" name="remaining-uses" id="multiUseCount">
</div>
</div>
<div class="form-group form-check" style="margin-top: 1rem; margin-bottom: 1rem;">
<input class="form-check-input" type="checkbox" value="" name="no-limit" id="noUseLimit" onchange="document.getElementById('multiUseCount').disabled = this.checked; if (this.checked) { document.getElementById('noLimitWarning').style = 'display: block;' } else { document.getElementById('noLimitWarning').style = 'display: none;'; }">
<label class="form-check-label" for="noUseLimit">
No use limit
</label>
<div id="noLimitWarning" class="form-text" style="display: none;">Warning: Unlimited usage invites pose a risk if published online.</div>
</div>
{% if email_enabled %}
<div class="form-group">
<label for="send_to_address">Send invite to address</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div>
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group d-flex float-right">
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">
Generate
</button>
</div>
</div>
</div>
</form>
</div>
</div>
@@ -202,6 +320,7 @@
<p>{{ contactMessage }}</p>
</div>
</div>
<script src="serialize.js"></script>
<script src="admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,944 @@
// Substitution from jinja
const cssFile = "{{ css_file }}";
// To grab theme preference
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for (let c of ca) {
while(c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
// Used for theme change animation
function whichTransitionEvent() {
let t;
let el = document.createElement('fakeelement');
let transitions = {
'transition': 'transitionend',
'OTransition': 'oTransitionEnd',
'MozTransition': 'transitionend',
'WebkitTransition': 'webkitTransitionEnd'
};
for (t in transitions) {
if (el.style[t] !== undefined) {
return transitions[t];
}
}
}
var transitionEndEvent = whichTransitionEvent();
// Toggles between light and dark themes
function toggleCSS() {
let cssEl = document.querySelectorAll('link[rel="stylesheet"][type="text/css"]')[0];
let href = "bs" + bsVersion;
if (cssEl.href.includes(href + "-jf")) {
href += ".css";
} else {
href += "-jf.css";
}
cssEl.href = href
document.cookie = "css=" + href;
}
// Toggles between light and dark themes, but runs animation if necessary (dependent on window width for performance)
var buttonWidth = 0;
function toggleCSSAnim(el) {
let switchToColor = window.getComputedStyle(document.body, null).backgroundColor;
let maxWidth = 1500;
if (window.innerWidth < maxWidth) {
// Calculate minimum radius to cover whole screen
let radius = Math.sqrt(Math.pow(window.innerWidth, 2) + Math.pow(window.innerHeight, 2));
let currentRadius = el.getBoundingClientRect().width / 2;
let scale = radius / currentRadius;
buttonWidth = window.getComputedStyle(el, null).width;
document.body.classList.remove('smooth-transition');
el.style.transform = `scale(${scale})`;
el.style.color = switchToColor;
el.addEventListener(transitionEndEvent, function() {
if (this.style.transform.length != 0) {
toggleCSS();
this.style.removeProperty('transform');
document.body.classList.add('smooth-transition');
};
}, false);
} else {
toggleCSS();
el.style.color = switchToColor;
}
}
var buttonColor = "custom";
// Predefined colors for 'theme' button
if (cssFile.includes("jf")) {
buttonColor = "rgb(255,255,255)";
} else if (cssFile == ('bs' + bsVersion + '.css')) {
buttonColor = "rgb(16,16,16)";
}
if (buttonColor != "custom") {
let fakeButton = document.createElement('i');
fakeButton.classList.add('fa', 'fa-circle', 'circle');
fakeButton.style = `color: ${buttonColor}; margin-left: 0.4rem;`;
fakeButton.id = "fakeButton";
let switchButton = document.createElement('button');
switchButton.classList.add('btn', 'btn-secondary');
switchButton.textContent = "Theme";
switchButton.onclick = function() {
let fakeButton = document.getElementById('fakeButton');
toggleCSSAnim(fakeButton);
};
let group = document.getElementById('headerButtons');
switchButton.appendChild(fakeButton);
group.appendChild(switchButton);
}
{% if bsVersion == 5 %}
function createModal(id, find = false) {
if (find) {
return bootstrap.Modal.getInstance(document.getElementById(id));
}
return new bootstrap.Modal(document.getElementById(id));
}
{% elif bsVersion == 4 %}
let send_to_addess_enabled = document.getElementById('send_to_address_enabled');
if (typeof(send_to_address_enabled) != 'undefined') {
send_to_address_enabled.classList.remove('form-check-input');
}
let multiUseEnabled = document.getElementById('multiUseEnabled');
if (typeof(multiUseEnabled) != 'undefined') {
multiUseEnabled.classList.remove('form-check-input');
}
function createModal(id, find = false) {
return {
show: function() {
return $('#' + id).modal('show');
},
hide: function() {
return $('#' + id).modal('hide');
}
};
}
{% endif %}
function triggerTooltips() {
{% if bsVersion == 5 %}
document.getElementById('settingsMenu').addEventListener('shown.bs.modal', function() {
{% elif bsVersion == 4 %}
$('#settingsMenu').on('shown.bs.modal', function() {
{% endif %}
// Hacky way to ensure anything dependent on checkbox state is disabled if necessary by just clicking them
let checkboxes = document.getElementById('settingsMenu').querySelectorAll('input[type="checkbox"]');
for (checkbox of checkboxes) {
checkbox.click();
checkbox.click();
}
let tooltips = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
tooltips.map(function(el) {
{% if bsVersion == 5 %}
return new bootstrap.Tooltip(el);
{% elif bsVersion == 4 %}
return $(el).tooltip();
{% endif %}
});
});
}
var loginModal = createModal('login');
var settingsModal = createModal('settingsMenu');
var userDefaultsModal = createModal('userDefaults');
var usersModal = createModal('users');
var restartModal = createModal('restartModal');
// Parsed invite: [<code>, <expires in _>, <1: Empty invite (no delete/link), 0: Actual invite>, <email address>, <remaining uses>, [<used-by>], <date created>]
function parseInvite(invite, empty = false) {
if (empty) {
return ["None", "", 1];
}
let i = [invite["code"], "", 0, invite["email"]];
let time = ""
for (m of ["days", "hours", "minutes"]) {
if (invite[m] != 0) {
time += `${invite[m]}${m[0]} `;
}
}
i[1] = `Expires in ${time.slice(0, -1)}`;
if ('remaining-uses' in invite) {
i[4] = invite['remaining-uses'];
}
if (invite['no-limit']) {
i[4] = '∞';
}
if ('used-by' in invite) {
i[5] = invite['used-by'];
} else {
i[5] = [];
}
if ('created' in invite) {
i[6] = invite['created'];
}
return i;
}
function addItem(parsedInvite) {
let links = document.getElementById('invites');
let itemContainer = document.createElement('div');
itemContainer.id = parsedInvite[0];
let listItem = document.createElement('div');
// listItem.id = parsedInvite[0];
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
let code = document.createElement('div');
code.classList.add('d-flex', 'align-items-center', 'font-monospace');
let codeLink = document.createElement('a');
codeLink.setAttribute('style', 'margin-right: 0.5rem;');
codeLink.textContent = parsedInvite[0].replace(/-/g, '-');
code.appendChild(codeLink);
listItem.appendChild(code);
let listRight = document.createElement('div');
let listText = document.createElement('span');
listText.id = parsedInvite[0] + '_expiry';
listText.setAttribute('style', 'margin-right: 1rem;');
listText.textContent = parsedInvite[1];
listRight.appendChild(listText);
if (parsedInvite[2] == 0) {
let inviteCode = window.location.href.split('#')[0] + 'invite/' + parsedInvite[0];
//
codeLink.href = inviteCode;
let copyButton = document.createElement('i');
copyButton.onclick = function() { toClipboard(inviteCode); };
copyButton.classList.add('fa', 'fa-clipboard', 'icon-button');
code.appendChild(copyButton);
if (parsedInvite[3] !== undefined) {
let sentTo = document.createElement('span');
sentTo.classList.add('text-muted');
sentTo.setAttribute('style', 'margin-left: 0.4rem; font-style: italic, font-size: 0.75rem;');
if (!parsedInvite[3].includes('Failed to send to')) {
sentTo.textContent = "Sent to ";
}
sentTo.textContent += parsedInvite[3];
code.appendChild(sentTo);
}
let deleteButton = document.createElement('button');
deleteButton.onclick = function() { deleteInvite(parsedInvite[0]); };
deleteButton.classList.add('btn', 'btn-outline-danger');
deleteButton.textContent = "Delete";
listRight.appendChild(deleteButton);
let dropButton = document.createElement('i');
dropButton.classList.add('fa', 'fa-angle-down', 'collapsed', 'icon-button', 'not-rotated');
dropButton.setAttribute('data-toggle', 'collapse');
dropButton.setAttribute('aria-expanded', 'false');
dropButton.setAttribute('data-target', '#' + CSS.escape(parsedInvite[0]) + '_collapse');
dropButton.onclick = function() {
if (this.classList.contains('rotated')) {
this.classList.remove('rotated');
this.classList.add('not-rotated');
} else {
this.classList.remove('not-rotated');
this.classList.add('rotated');
}
};
dropButton.setAttribute('style', 'margin-left: 1rem;');
listRight.appendChild(dropButton);
}
listItem.appendChild(listRight);
itemContainer.appendChild(listItem);
if (parsedInvite[2] == 0) {
let itemDropdown = document.createElement('div');
itemDropdown.id = parsedInvite[0] + '_collapse';
itemDropdown.classList.add('collapse');
let dropdownContent = document.createElement('div');
dropdownContent.classList.add('container', 'row', 'align-items-start', 'card-body');
let dropdownLeft = document.createElement('div');
dropdownLeft.classList.add('col');
let leftList = document.createElement('ul');
leftList.classList.add('list-group', 'list-group-flush');
if (typeof(parsedInvite[6]) != 'undefined') {
let createdDate = document.createElement('li');
createdDate.classList.add('list-group-item', 'py-1');
createdDate.textContent = `Created: ${parsedInvite[6]}`;
leftList.appendChild(createdDate);
}
let remainingUses = document.createElement('li');
remainingUses.classList.add('list-group-item', 'py-1');
remainingUses.id = parsedInvite[0] + '_remainingUses';
remainingUses.textContent = `Remaining uses: ${parsedInvite[4]}`;
leftList.appendChild(remainingUses);
dropdownLeft.appendChild(leftList);
dropdownContent.appendChild(dropdownLeft);
let dropdownRight = document.createElement('div');
dropdownRight.id = parsedInvite[0] + '_usersCreated';
dropdownRight.classList.add('col');
if (parsedInvite[5].length != 0) {
let userList = document.createElement('ul');
userList.classList.add('list-group', 'list-group-flush');
userList.innerHTML = '<li class="list-group-item py-1">Users created:</li>';
for (let user of parsedInvite[5]) {
let li = document.createElement('li');
li.classList.add('list-group-item', 'py-1', 'disabled');
let username = document.createElement('div');
username.classList.add('d-flex', 'float-left');
username.textContent = user[0];
li.appendChild(username);
let date = document.createElement('div');
date.classList.add('d-flex', 'float-right');
date.textContent = user[1];
li.appendChild(date);
userList.appendChild(li);
}
dropdownRight.appendChild(userList);
}
dropdownContent.appendChild(dropdownRight);
itemDropdown.appendChild(dropdownContent);
itemContainer.appendChild(itemDropdown);
}
links.appendChild(itemContainer);
}
function updateInvite(parsedInvite) {
let expiry = document.getElementById(parsedInvite[0] + '_expiry');
expiry.textContent = parsedInvite[1];
let remainingUses = document.getElementById(parsedInvite[0] + '_remainingUses');
if (remainingUses) {
remainingUses.textContent = `Remaining uses: ${parsedInvite[4]}`;
}
if (parsedInvite[5].length != 0) {
let usersCreated = document.getElementById(parsedInvite[0] + '_usersCreated');
let dropdownRight = document.createElement('div');
dropdownRight.id = parsedInvite[0] + '_usersCreated';
dropdownRight.classList.add('col');
let userList = document.createElement('ul');
userList.classList.add('list-group', 'list-group-flush');
userList.innerHTML = '<li class="list-group-item py-1">Users created:</li>';
for (let user of parsedInvite[5]) {
let li = document.createElement('li');
li.classList.add('list-group-item', 'py-1', 'disabled');
let username = document.createElement('div');
username.classList.add('d-flex', 'float-left');
username.textContent = user[0];
li.appendChild(username);
let date = document.createElement('div');
date.classList.add('d-flex', 'float-right');
date.textContent = user[1];
li.appendChild(date);
userList.appendChild(li);
}
dropdownRight.appendChild(userList);
usersCreated.replaceWith(dropdownRight);
}
}
// delete from list on page
function removeInvite(code) {
let item = document.getElementById(code);
item.parentNode.removeChild(item);
}
function generateInvites(empty = false) {
if (empty === false) {
let 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 {
for (let invite of data['invites']) {
let match = false;
let items = document.getElementById('invites').children;
for (let item of items) {
if (item.id == invite['code']) {
match = true;
updateInvite(parseInvite(invite));
}
}
if (match == false) {
addItem(parseInvite(invite));
}
}
let items = document.getElementById('invites').children;
for (let item of items) {
var exists = false;
for (let invite of data['invites']) {
if (item.id == invite['code']) {
exists = true;
}
}
if (exists == false) {
removeInvite(item.id);
}
}
}
}
};
req.send();
} else if (empty === true) {
document.getElementById('invites').textContent = '';
addItem(parseInvite([], true));
}
}
// actually delete invite
function deleteInvite(code) {
let send = JSON.stringify({ "code": code });
let 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);
}
// Add numbers to select element
function addOptions(length, selectElement) {
for (let v = 0; v <= length; v++) {
let opt = document.createElement('option');
opt.textContent = v;
opt.value = v;
selectElement.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);
}
}
function fixCheckboxes() {
let send_to_address = [document.getElementById('send_to_address'), document.getElementById('send_to_address_enabled')]
if (send_to_address[0] != null) {
send_to_address[0].disabled = !send_to_address[1].checked;
}
let multiUseEnabled = document.getElementById('multiUseEnabled');
let multiUseCount = document.getElementById('multiUseCount');
let noUseLimit = document.getElementById('noUseLimit');
multiUseCount.disabled = !multiUseEnabled.checked;
noUseLimit.checked = false;
noUseLimit.disabled = !multiUseEnabled.checked;
}
fixCheckboxes();
document.getElementById('inviteForm').onsubmit = function() {
let 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');
if (!send_object['multiple-uses'] || send_object['no-limit']) {
delete send_object['remaining-uses'];
}
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'];
}
}
let send = JSON.stringify(send_object);
let 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 = "";
let details = serializeForm('loginForm');
let errorArea = document.getElementById('loginErrorArea');
errorArea.textContent = '';
let 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...';
let req = new XMLHttpRequest();
req.responseType = 'json';
req.onreadystatechange = function() {
if (this.readyState == 4) {
if (this.status == 401) {
button.disabled = false;
button.textContent = 'Login';
let wrongPassword = document.createElement('div');
wrongPassword.classList.add('alert', 'alert-danger');
wrongPassword.setAttribute('role', 'alert');
wrongPassword.textContent = "Incorrect username or password.";
errorArea.appendChild(wrongPassword);
} else {
const data = this.response;
window.token = data['token'];
generateInvites();
const interval = setInterval(function() { generateInvites(); }, 60 * 1000);
let day = document.getElementById('days');
addOptions(30, day);
day.selected = "0";
let hour = document.getElementById('hours');
addOptions(24, hour);
hour.selected = "0";
let 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...';
let 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) {
let users = req.response['users'];
let radioList = document.getElementById('defaultUserRadios');
radioList.textContent = '';
if (document.getElementById('setDefaultUser')) {
document.getElementById('setDefaultUser').remove();
}
let first = true;
for (user of users) {
let radio = document.createElement('div');
radio.classList.add('radio');
if (first) {
const checked = 'checked';
first = false;
} else {
const checked = '';
};
radio.innerHTML =
`<label><input type="radio" name="defaultRadios" id="default_${user['name']}" style="margin-right: 1rem;" ${checked}>${user['name']}</label>`;
radioList.appendChild(radio);
}
let button = document.getElementById('openDefaultsWizard');
button.disabled = false;
button.innerHTML = 'Set new account defaults';
let 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...';
let button = document.getElementById('storeDefaults');
let radios = document.getElementsByName('defaultRadios');
for (let radio of radios) {
if (radio.checked) {
let data = {
'username': radio.id.slice(8),
'homescreen': false};
if (document.getElementById('storeDefaultHomescreen').checked) {
data['homescreen'] = true;
}
let 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() { userDefaultsModal.hide(); }, 1000);
} else {
button.textContent = "Failed";
button.classList.remove('btn-primary');
button.classList.add('btn-danger');
setTimeout(function() {
let 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));
}
}
};
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...';
let 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) {
let list = document.getElementById('userList');
list.textContent = '';
if (document.getElementById('saveUsers')) {
document.getElementById('saveUsers').remove();
}
let users = req.response['users'];
for (let user of users) {
let entry = document.createElement('div');
entry.classList.add('form-group', 'list-group-item', 'py-1');
entry.id = 'user_' + user['name'];
let label = document.createElement('label');
label.classList.add('d-inline-block');
label.setAttribute('for', 'address_' + user['email']);
label.textContent = user['name'];
entry.appendChild(label);
let address = document.createElement('input');
address.setAttribute('type', 'email');
address.readOnly = true;
address.classList.add('form-control-plaintext', 'text-muted', 'd-inline-block', 'addressText');
address.id = 'address_' + user['email'];
if (typeof(user['email']) != 'undefined') {
address.value = user['email'];
address.setAttribute('style', 'width: auto; margin-left: 2%;');
}
let 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');
let 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 == '') {
addressElement.placeholder = 'Email Address';
address.setAttribute('style', 'width: auto; margin-left: 2%;');
}
if (document.getElementById('saveUsers') == null) {
let footer = document.getElementById('userFooter')
let saveUsers = document.createElement('input');
saveUsers.classList.add('btn', 'btn-primary');
saveUsers.setAttribute('type', 'button');
saveUsers.value = 'Save Changes';
saveUsers.id = 'saveUsers';
saveUsers.onclick = function() {
let send = {}
let entries = document.getElementById('userList').children;
for (let entry of entries) {
if (typeof(entry.getElementsByTagName('input')[0]) != 'undefined') {
const name = entry.id.replace(/user_/g, '');
const address = entry.getElementsByTagName('input')[0].value;
send[name] = address;
}
}
send = JSON.stringify(send);
let 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);
};
let 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 () {
let 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) {
let settingsList = document.getElementById('settingsList');
settingsList.textContent = '';
config = this.response;
for (let section of Object.keys(config)) {
let sectionCollapse = document.createElement('div');
sectionCollapse.classList.add('collapse');
sectionCollapse.id = section;
let sectionTitle = config[section]['meta']['name'];
let sectionDescription = config[section]['meta']['description'];
let entryListID = section + '_entryList';
let sectionFooter = section + '_footer';
let 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') {
let entryName = config[section][entry]['name'];
let 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')) {
let 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;
};
let entryValue = config[section][entry]['value'];
let entryType = config[section][entry]['type'];
let 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');
let entryOptions = config[section][entry]['options'];
let innerGroup = `
<label for="${section}_${entry}">${entryName}</label>
<select class="form-control" id="${section}_${entry}">
`;
for (let entryOption of entryOptions) {
if (entryOption == entryValue) {
var selected = 'selected';
} else {
var selected = '';
}
innerGroup += `
<option value="${entryOption}" ${selected}>${entryOption}</option>
`;
}
innerGroup += '</select>';
entryGroup.innerHTML = innerGroup;
entryGroup.getElementsByClassName('form-control')[0].required = required;
}
sectionCollapse.getElementsByClassName(entryListID)[0].appendChild(entryGroup);
}
}
let 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();
function sendConfig(modalId) {
let modal = document.getElementById(modalId);
let send = JSON.stringify(modifiedConfig);
let 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();
}
}
}
};
req.send(send);
}
document.getElementById('settingsSave').onclick = function() {
modifiedConfig = {};
var restart_setting_changed = false;
var settings_changed = false;
for (let section of Object.keys(config)) {
for (let entry of Object.keys(config[section])) {
if (entry != 'meta') {
let entryID = section + '_' + entry;
let 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) {
document.getElementById('applyRestarts').onclick = function(){sendConfig('restartModal');};
settingsModal.hide();
restartModal.show();
} else if (settings_changed) {
sendConfig('settingsMenu');
} else {
settingsModal.hide();
}
}

View File

@@ -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,26 +155,37 @@
}
submitButton.replaceChild(newSpan, oldSpan);
};
$("form").submit(function() {
document.getElementById('accountForm').onsubmit = function() {
if (document.getElementById('errorMessage')) {
document.getElementById('errorMessage').remove();
}
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'];
if ('error' in data) {
var text = document.createTextNode(data['error']);
// <div class="alert alert-danger" id="errorBox"></div>
var data = this.response;
if ('error' in data || data['success'] == false) {
if (typeof(data['error']) != 'undefined') {
var errorMessage = data['error'];
} else {
var errorMessage = 'Unknown Error';
}
var text = document.createTextNode(errorMessage);
var error = document.createElement('button');
error.classList.add('btn', 'btn-outline-danger');
error.setAttribute('disabled', '');
error.appendChild(text);
error.id = 'errorMessage';
document.getElementById('errorBox').appendChild(error);
} else {
var valid = true
@@ -172,13 +209,14 @@
};
};
if (valid == true) {
$('#successBox').modal('show');
successBox.show();
};
}
}
});
};
};
};
req.send(send);
return false;
});
};
</script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,35 @@
import configparser
import json
from pathlib import Path
def generate_ini(base_file, ini_file, version):
"""
Generates .ini file from config-base file.
"""
with open(Path(base_file), "r") as f:
config_base = json.load(f)
ini = configparser.RawConfigParser(allow_no_value=True)
for section in config_base:
ini.add_section(section)
for entry in config_base[section]:
if "description" in config_base[section][entry]:
ini.set(section, "; " + config_base[section][entry]["description"])
if entry != "meta":
value = config_base[section][entry]["value"]
if isinstance(value, bool):
value = str(value).lower()
else:
value = str(value)
ini.set(section, entry, value)
ini["jellyfin"]["version"] = version
ini["jellyfin"]["device_id"] = ini["jellyfin"]["device_id"].replace(
"{version}", version
)
with open(Path(ini_file), "w") as config_file:
ini.write(config_file)
return True

View File

@@ -79,7 +79,10 @@ class Jellyfin:
"User-Agent": self.useragent,
"X-Emby-Authorization": self.auth,
}
self.info = requests.get(self.server + "/System/Info/Public").json()
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):
"""
@@ -187,7 +190,7 @@ class Jellyfin:
)
def newUser(self, username: str, password: str):
for user in self.getUsers():
for user in self.getUsers(public=False):
if user["Name"] == username:
raise self.UserExistsError
response = requests.post(

View File

@@ -1,9 +1,8 @@
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
from jellyfin_accounts.web_api import resp
import os
if first_run:

View File

@@ -1,9 +1,15 @@
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
if config.getboolean("ui", "bs5"):
bsVersion = 5
else:
bsVersion = 4
@app.errorhandler(404)
@@ -11,9 +17,8 @@ def page_not_found(e):
return (
render_template(
"404.html",
css_href=css["href"],
css_integrity=css["integrity"],
css_crossorigin=css["crossorigin"],
bs5=config.getboolean("ui", "bs5"),
css_file=css_file,
contactMessage=config["ui"]["contact_message"],
),
404,
@@ -25,9 +30,8 @@ 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"],
bs5=config.getboolean("ui", "bs5"),
css_file=css_file,
contactMessage="",
email_enabled=config.getboolean("invite_emails", "enabled"),
)
@@ -36,13 +40,18 @@ def admin():
@app.route("/<path:path>")
def static_proxy(path):
if "html" not in path:
if "admin.js" in path:
return (
render_template("admin.js", bsVersion=bsVersion, css_file=css_file),
200,
{"Content-Type": "text/javascript"},
)
return app.send_static_file(path)
return (
render_template(
"404.html",
css_href=css["href"],
css_integrity=css["integrity"],
css_crossorigin=css["crossorigin"],
bs5=config.getboolean("ui", "bs5"),
css_file=css_file,
contactMessage=config["ui"]["contact_message"],
),
404,
@@ -59,9 +68,8 @@ def inviteProxy(path):
email = ""
return render_template(
"form.html",
css_href=css["href"],
css_integrity=css["integrity"],
css_crossorigin=css["crossorigin"],
bs5=config.getboolean("ui", "bs5"),
css_file=css_file,
contactMessage=config["ui"]["contact_message"],
helpMessage=config["ui"]["help_message"],
successMessage=config["ui"]["success_message"],
@@ -69,6 +77,7 @@ def inviteProxy(path):
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)
@@ -76,8 +85,7 @@ def inviteProxy(path):
log.debug("Attempted use of invalid invite")
return render_template(
"invalidCode.html",
css_href=css["href"],
css_integrity=css["integrity"],
css_crossorigin=css["crossorigin"],
bs5=config.getboolean("ui", "bs5"),
css_file=css_file,
contactMessage=config["ui"]["contact_message"],
)

View File

@@ -4,39 +4,65 @@ 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
def format_datetime(dt):
result = dt.strftime(config["email"]["date_format"])
if config.getboolean("email", "use_24h"):
result += f' {dt.strftime("%H:%M")}'
else:
r = jsonify({"success": False})
r.status_code = code
return r
result += f' {dt.strftime("%I:%M %p")}'
return result
def checkInvite(code, delete=False):
def checkInvite(code, used=False, username=None):
current_time = datetime.datetime.now()
invites = dict(data_store.invites)
match = False
for invite in invites:
if (
"remaining-uses" not in invites[invite]
and "no-limit" not in invites[invite]
):
invites[invite]["remaining-uses"] = 1
expiry = datetime.datetime.strptime(
invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
)
if current_time >= expiry:
log.debug(f"Housekeeping: Deleting old invite {invite}")
if current_time >= expiry or (
"no-limit" not in invites[invite] and invites[invite]["remaining-uses"] < 1
):
log.debug(f"Housekeeping: Deleting expired invite {invite}")
del data_store.invites[invite]
elif invite == code:
match = True
if delete:
del data_store.invites[code]
if used:
delete = False
inv = dict(data_store.invites[code])
if "used-by" not in inv:
inv["used-by"] = []
if "remaining-uses" in inv:
if inv["remaining-uses"] == 1:
delete = True
del data_store.invites[code]
elif "no-limit" not in invites[invite]:
inv["remaining-uses"] -= 1
inv["used-by"].append([username, format_datetime(current_time)])
if not delete:
data_store.invites[code] = inv
return match
@@ -159,7 +185,7 @@ def newUser():
return jsonify({"error": error})
except:
return jsonify({"error": "Unknown error"})
checkInvite(data["code"], delete=True)
checkInvite(data["code"], used=True, username=data["username"])
if user.status_code == 200:
try:
policy = data_store.user_template
@@ -202,9 +228,19 @@ def newUser():
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(
days=int(data["days"]), hours=int(data["hours"]), minutes=int(data["minutes"])
)
invite_code = secrets.token_urlsafe(16)
invite = {}
invite["created"] = format_datetime(current_time)
if data["multiple-uses"]:
if data["no-limit"]:
invite["no-limit"] = True
else:
invite["remaining-uses"] = int(data["remaining-uses"])
else:
invite["remaining-uses"] = 1
log.debug(f"Creating new invite: {invite_code}")
valid_till = current_time + delta
invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f")
@@ -247,9 +283,20 @@ def getInvites():
valid_for = expiry - current_time
invite = {
"code": code,
"days": valid_for.days,
"hours": valid_for.seconds // 3600,
"minutes": (valid_for.seconds // 60) % 60,
}
if "created" in invites[code]:
invite["created"] = invites[code]["created"]
if "used-by" in invites[code]:
invite["used-by"] = invites[code]["used-by"]
if "no-limit" in invites[code]:
invite["no-limit"] = invites[code]["no-limit"]
if "remaining-uses" in invites[code]:
invite["remaining-uses"] = invites[code]["remaining-uses"]
else:
invite["remaining-uses"] = 1
if "email" in invites[code]:
invite["email"] = invites[code]["email"]
response["invites"].append(invite)
@@ -327,4 +374,46 @@ def setDefaults():
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

8
jf-accounts.service Normal file
View File

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

988
package-lock.json generated Normal file
View File

@@ -0,0 +1,988 @@
{
"name": "jellyfin-accounts",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@babel/runtime-corejs3": {
"version": "7.10.4",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.4.tgz",
"integrity": "sha512-BFlgP2SoLO9HJX9WBwN67gHWMBhDX/eDz64Jajd6mR/UAUzqrNMm99d4qHnVaKscAElZoFiPv+JpR/Siud5lXw==",
"requires": {
"core-js-pure": "^3.0.0",
"regenerator-runtime": "^0.13.4"
}
},
"@nodelib/fs.scandir": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz",
"integrity": "sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==",
"requires": {
"@nodelib/fs.stat": "2.0.3",
"run-parallel": "^1.1.9"
}
},
"@nodelib/fs.stat": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz",
"integrity": "sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA=="
},
"@nodelib/fs.walk": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz",
"integrity": "sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==",
"requires": {
"@nodelib/fs.scandir": "2.1.3",
"fastq": "^1.6.0"
}
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ=="
},
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
},
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"requires": {
"color-convert": "^1.9.0"
}
},
"anymatch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
"integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"requires": {
"sprintf-js": "~1.0.2"
}
},
"array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="
},
"at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="
},
"autoprefixer": {
"version": "9.8.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.4.tgz",
"integrity": "sha512-84aYfXlpUe45lvmS+HoAWKCkirI/sw4JK0/bTeeqgHYco3dcsOn0NqdejISjptsYwNji/21dnkDri9PsYKk89A==",
"requires": {
"browserslist": "^4.12.0",
"caniuse-lite": "^1.0.30001087",
"colorette": "^1.2.0",
"normalize-range": "^0.1.2",
"num2fraction": "^1.2.2",
"postcss": "^7.0.32",
"postcss-value-parser": "^4.1.0"
}
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"binary-extensions": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz",
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ=="
},
"bootstrap": {
"version": "5.0.0-alpha1",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.0.0-alpha1.tgz",
"integrity": "sha512-iwKneP2pLXl8lN0YpnOuOARiNPTzmh/4cw+Un86u4OqrMLuQpyMC7nO07hvivvcg0B/ektJPjuPnS1s+YmRK9A=="
},
"bootstrap4": {
"version": "npm:bootstrap@4.5.0",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.5.0.tgz",
"integrity": "sha512-Z93QoXvodoVslA+PWNdk23Hze4RBYIkpb5h8I2HY2Tu2h7A0LpAgLcyrhrSUyo2/Oxm2l1fRZPs1e5hnxnliXA=="
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"requires": {
"fill-range": "^7.0.1"
}
},
"browserslist": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.13.0.tgz",
"integrity": "sha512-MINatJ5ZNrLnQ6blGvePd/QOz9Xtu+Ne+x29iQSCHfkU5BugKVJwZKn/iiL8UbpIpa3JhviKjz+XxMo0m2caFQ==",
"requires": {
"caniuse-lite": "^1.0.30001093",
"electron-to-chromium": "^1.3.488",
"escalade": "^3.0.1",
"node-releases": "^1.1.58"
}
},
"caller-callsite": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz",
"integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=",
"requires": {
"callsites": "^2.0.0"
}
},
"caller-path": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz",
"integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=",
"requires": {
"caller-callsite": "^2.0.0"
}
},
"callsites": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
"integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA="
},
"camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
},
"caniuse-lite": {
"version": "1.0.30001094",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001094.tgz",
"integrity": "sha512-ufHZNtMaDEuRBpTbqD93tIQnngmJ+oBknjvr0IbFympSdtFpAUFmNv4mVKbb53qltxFx0nK3iy32S9AqkLzUNA=="
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"dependencies": {
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
"chokidar": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.0.tgz",
"integrity": "sha512-aXAaho2VJtisB/1fg1+3nlLJqGOuewTzQpd/Tz0yTg2R0e4IGtshYvtjowyEumcBv2z+y4+kc75Mz7j5xJskcQ==",
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.1.2",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.4.0"
}
},
"clean-css": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
"integrity": "sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==",
"requires": {
"source-map": "~0.6.0"
}
},
"clean-css-cli": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/clean-css-cli/-/clean-css-cli-4.3.0.tgz",
"integrity": "sha512-8GHZfr+mG3zB/Lgqrr27qHBFsPSn0fyEI3f2rIZpxPxUbn2J6A8xyyeBRVTW8duDuXigN0s80vsXiXJOEFIO5Q==",
"requires": {
"clean-css": "^4.2.1",
"commander": "2.x",
"glob": "7.x"
}
},
"cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"requires": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"requires": {
"color-name": "1.1.3"
}
},
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"colorette": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz",
"integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw=="
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"core-js-pure": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.5.tgz",
"integrity": "sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA=="
},
"cosmiconfig": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz",
"integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==",
"requires": {
"import-fresh": "^2.0.0",
"is-directory": "^0.3.1",
"js-yaml": "^3.13.1",
"parse-json": "^4.0.0"
}
},
"decamelize": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-3.2.0.tgz",
"integrity": "sha512-4TgkVUsmmu7oCSyGBm5FvfMoACuoh9EOidm7V5/J2X2djAwwt57qb3F2KMP2ITqODTCSwb+YRV+0Zqrv18k/hw==",
"requires": {
"xregexp": "^4.2.4"
}
},
"dependency-graph": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.9.0.tgz",
"integrity": "sha512-9YLIBURXj4DJMFALxXw9K3Y3rwb5Fk0X5/8ipCzaN84+gKxoHK43tVKRNakCQbiEx07E8Uwhuq21BpUagFhZ8w=="
},
"dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"requires": {
"path-type": "^4.0.0"
}
},
"electron-to-chromium": {
"version": "1.3.488",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.488.tgz",
"integrity": "sha512-NReBdOugu1yl8ly+0VDtiQ6Yw/1sLjnvflWq0gvY1nfUXU2PbA+1XAVuEb7ModnwL/MfUPjby7e4pAFnSHiy6Q=="
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"requires": {
"is-arrayish": "^0.2.1"
}
},
"escalade": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.0.1.tgz",
"integrity": "sha512-DR6NO3h9niOT+MZs7bjxlj2a1k+POu5RN8CLTPX2+i78bRi9eLe7+0zXgUHMnGXWybYcL61E9hGhPKqedy8tQA=="
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
},
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"fast-glob": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz",
"integrity": "sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==",
"requires": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.0",
"merge2": "^1.3.0",
"micromatch": "^4.0.2",
"picomatch": "^2.2.1"
}
},
"fastq": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.8.0.tgz",
"integrity": "sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q==",
"requires": {
"reusify": "^1.0.4"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"requires": {
"to-regex-range": "^5.0.1"
}
},
"find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"requires": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
}
},
"fs-extra": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz",
"integrity": "sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==",
"requires": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^1.0.0"
}
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"fsevents": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
"optional": true
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"get-stdin": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-7.0.0.tgz",
"integrity": "sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ=="
},
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"glob-parent": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz",
"integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==",
"requires": {
"is-glob": "^4.0.1"
}
},
"globby": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz",
"integrity": "sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==",
"requires": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.1.1",
"ignore": "^5.1.4",
"merge2": "^1.3.0",
"slash": "^3.0.0"
}
},
"graceful-fs": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
"integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw=="
},
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
},
"ignore": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
},
"import-cwd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
"integrity": "sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk=",
"requires": {
"import-from": "^2.1.0"
}
},
"import-fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz",
"integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=",
"requires": {
"caller-path": "^2.0.0",
"resolve-from": "^3.0.0"
}
},
"import-from": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-from/-/import-from-2.1.0.tgz",
"integrity": "sha1-M1238qev/VOqpHHUuAId7ja387E=",
"requires": {
"resolve-from": "^3.0.0"
}
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"requires": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0="
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-directory": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz",
"integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE="
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"is-glob": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
"integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"js-yaml": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
"integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
}
},
"json-parse-better-errors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
"integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="
},
"jsonfile": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.0.1.tgz",
"integrity": "sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==",
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^1.0.0"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"requires": {
"p-locate": "^4.1.0"
}
},
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
},
"log-symbols": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz",
"integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==",
"requires": {
"chalk": "^2.0.1"
}
},
"merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
},
"micromatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
"integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==",
"requires": {
"braces": "^3.0.1",
"picomatch": "^2.0.5"
}
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"requires": {
"brace-expansion": "^1.1.7"
}
},
"node-releases": {
"version": "1.1.58",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.58.tgz",
"integrity": "sha512-NxBudgVKiRh/2aPWMgPR7bPTX0VPmGx5QBwCtdHitnqFE5/O8DeBXuIMH1nwNnw/aMo6AjOrpsHzfY3UbUJ7yg=="
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
},
"normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI="
},
"num2fraction": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
"integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4="
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"requires": {
"wrappy": "1"
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"requires": {
"p-limit": "^2.2.0"
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
"requires": {
"error-ex": "^1.3.1",
"json-parse-better-errors": "^1.0.1"
}
},
"path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
},
"path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
},
"path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
},
"picomatch": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg=="
},
"pify": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw="
},
"postcss": {
"version": "7.0.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz",
"integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==",
"requires": {
"chalk": "^2.4.2",
"source-map": "^0.6.1",
"supports-color": "^6.1.0"
}
},
"postcss-cli": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-cli/-/postcss-cli-7.1.1.tgz",
"integrity": "sha512-bYQy5ydAQJKCMSpvaMg0ThPBeGYqhQXumjbFOmWnL4u65CYXQ16RfS6afGQpit0dGv/fNzxbdDtx8dkqOhhIbg==",
"requires": {
"chalk": "^4.0.0",
"chokidar": "^3.3.0",
"dependency-graph": "^0.9.0",
"fs-extra": "^9.0.0",
"get-stdin": "^7.0.0",
"globby": "^11.0.0",
"postcss": "^7.0.0",
"postcss-load-config": "^2.0.0",
"postcss-reporter": "^6.0.0",
"pretty-hrtime": "^1.0.3",
"read-cache": "^1.0.0",
"yargs": "^15.0.2"
},
"dependencies": {
"ansi-styles": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
"integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
"requires": {
"@types/color-name": "^1.1.1",
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"supports-color": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
"integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"postcss-load-config": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.1.0.tgz",
"integrity": "sha512-4pV3JJVPLd5+RueiVVB+gFOAa7GWc25XQcMp86Zexzke69mKf6Nx9LRcQywdz7yZI9n1udOxmLuAwTBypypF8Q==",
"requires": {
"cosmiconfig": "^5.0.0",
"import-cwd": "^2.0.0"
}
},
"postcss-reporter": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-6.0.1.tgz",
"integrity": "sha512-LpmQjfRWyabc+fRygxZjpRxfhRf9u/fdlKf4VHG4TSPbV2XNsuISzYW1KL+1aQzx53CAppa1bKG4APIB/DOXXw==",
"requires": {
"chalk": "^2.4.1",
"lodash": "^4.17.11",
"log-symbols": "^2.2.0",
"postcss": "^7.0.7"
}
},
"postcss-value-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
"integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ=="
},
"pretty-hrtime": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
"integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE="
},
"read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=",
"requires": {
"pify": "^2.3.0"
}
},
"readdirp": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.4.0.tgz",
"integrity": "sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==",
"requires": {
"picomatch": "^2.2.1"
}
},
"regenerator-runtime": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
},
"require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I="
},
"require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"resolve-from": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz",
"integrity": "sha1-six699nWiBvItuZTM17rywoYh0g="
},
"reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
},
"run-parallel": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.9.tgz",
"integrity": "sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q=="
},
"set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
},
"slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"string-width": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
"integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.0"
}
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"requires": {
"ansi-regex": "^5.0.0"
}
},
"supports-color": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"requires": {
"has-flag": "^3.0.0"
}
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"requires": {
"is-number": "^7.0.0"
}
},
"universalify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz",
"integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug=="
},
"which-module": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
"integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho="
},
"wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
"integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
"requires": {
"@types/color-name": "^1.1.1",
"color-convert": "^2.0.1"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
}
}
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
},
"xregexp": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.3.0.tgz",
"integrity": "sha512-7jXDIFXh5yJ/orPn4SXjuVrWWoi4Cr8jfV1eHv9CixKSbU+jY4mxfrBwAuDvupPNKpMUY+FeIqsVw/JLT9+B8g==",
"requires": {
"@babel/runtime-corejs3": "^7.8.3"
}
},
"y18n": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w=="
},
"yargs": {
"version": "15.4.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.0.tgz",
"integrity": "sha512-D3fRFnZwLWp8jVAAhPZBsmeIHY8tTsb8ItV9KaAaopmC6wde2u6Yw29JBIZHXw14kgkRnYmDgmQU4FVMDlIsWw==",
"requires": {
"cliui": "^6.0.0",
"decamelize": "^3.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
}
},
"yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"requires": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"dependencies": {
"decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
}
}
}
}
}

26
package.json Normal file
View File

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

71
poetry.lock generated
View File

@@ -162,6 +162,17 @@ MarkupSafe = ">=0.23"
[package.extras]
i18n = ["Babel (>=0.8)"]
[[package]]
category = "dev"
description = "Sass for Python: A straightforward binding of libsass for Python."
name = "libsass"
optional = false
python-versions = "*"
version = "0.20.0"
[package.dependencies]
six = "*"
[[package]]
category = "main"
description = "Safely add untrusted strings to HTML/XML markup."
@@ -189,6 +200,18 @@ 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"
@@ -259,6 +282,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"
@@ -312,6 +343,17 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.15.0"
[[package]]
category = "dev"
description = "tasks runner for python projects"
name = "taskipy"
optional = false
python-versions = ">=3.6,<4.0"
version = "1.2.1"
[package.dependencies]
toml = ">=0.10.0,<0.11.0"
[[package]]
category = "dev"
description = "Python Library for Tom's Obvious, Minimal Language"
@@ -380,7 +422,7 @@ dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-
watchdog = ["watchdog"]
[metadata]
content-hash = "f07c7cafa4edc558a016b9b7742290d7f28579b4e350762d2afbdce21f71796b"
content-hash = "fa8b5fb1ded41b673b8062a2bfc6467e6a484ff62b578147bec001d7d9d8ca16"
python-versions = "^3.6"
[metadata.files]
@@ -498,6 +540,21 @@ jinja2 = [
{file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"},
{file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"},
]
libsass = [
{file = "libsass-0.20.0-cp27-cp27m-macosx_10_14_intel.whl", hash = "sha256:107c409524c6a4ed14410fa9dafa9ee59c6bd3ecae75d73af749ab2b75685726"},
{file = "libsass-0.20.0-cp27-cp27m-win32.whl", hash = "sha256:98f6dee9850b29e62977a963e3beb3cfeb98b128a267d59d2c3d675e298c8d57"},
{file = "libsass-0.20.0-cp27-cp27m-win_amd64.whl", hash = "sha256:b077261a04ba1c213e932943208471972c5230222acb7fa97373e55a40872cbb"},
{file = "libsass-0.20.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e6a547c0aa731dcb4ed71f198e814bee0400ce04d553f3f12a53bc3a17f2a481"},
{file = "libsass-0.20.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:74f6fb8da58179b5d86586bc045c16d93d55074bc7bb48b6354a4da7ac9f9dfd"},
{file = "libsass-0.20.0-cp36-cp36m-win32.whl", hash = "sha256:a43f3830d83ad9a7f5013c05ce239ca71744d0780dad906587302ac5257bce60"},
{file = "libsass-0.20.0-cp36-cp36m-win_amd64.whl", hash = "sha256:fd19c8f73f70ffc6cbcca8139da08ea9a71fc48e7dfc4bb236ad88ab2d6558f1"},
{file = "libsass-0.20.0-cp37-abi3-macosx_10_14_x86_64.whl", hash = "sha256:8cf72552b39e78a1852132e16b706406bc76029fe3001583284ece8d8752a60a"},
{file = "libsass-0.20.0-cp37-cp37m-win32.whl", hash = "sha256:7555d9b24e79943cfafac44dbb4ca7e62105c038de7c6b999838c9ff7b88645d"},
{file = "libsass-0.20.0-cp37-cp37m-win_amd64.whl", hash = "sha256:794f4f4661667263e7feafe5cc866e3746c7c8a9192b2aa9afffdadcbc91c687"},
{file = "libsass-0.20.0-cp38-cp38-win32.whl", hash = "sha256:3bc0d68778b30b5fa83199e18795314f64b26ca5871e026343e63934f616f7f7"},
{file = "libsass-0.20.0-cp38-cp38-win_amd64.whl", hash = "sha256:5c8ff562b233734fbc72b23bb862cc6a6f70b1e9bf85a58422aa75108b94783b"},
{file = "libsass-0.20.0.tar.gz", hash = "sha256:b7452f1df274b166dc22ee2e9154c4adca619bcbbdf8041a7aa05f372a1dacbc"},
]
markupsafe = [
{file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"},
@@ -556,6 +613,10 @@ msgpack = [
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"},
@@ -578,6 +639,10 @@ 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"},
@@ -617,6 +682,10 @@ six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
]
taskipy = [
{file = "taskipy-1.2.1-py3-none-any.whl", hash = "sha256:99bdaf5b19791c2345806847147e0fc2d28e1ac9446058def5a8b6b3fc9f23e2"},
{file = "taskipy-1.2.1.tar.gz", hash = "sha256:5eb2c3b1606c896c7fa799848e71e8883b880759224958d07ba760e5db263175"},
]
toml = [
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},

View File

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

147
requirements.txt Normal file
View File

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

10
scss/README.md Normal file
View File

@@ -0,0 +1,10 @@
## 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**: It is assumed that Bootstrap 5 is installed in `../../node_modules/bootstrap` relative to itself, and Bootstrap 4 in `../../node_modules/bootstrap4`.
* Compilation requires dev dependencies (`poetry update`), bootstrap and some extra npm packages.
* If you're buildings from source, you can simply run `poetry run task compile-css` before building to automatically get deps and compile CSS.
* If you are creating custom css, run `poetry run task get-npm-deps` to only install the necessary dependencies. Follow along with the commands `scss/compile.py` runs to build your css and then set `custom_css` in your config as the path to your minified css and change the `theme` option to `Custom CSS`.

140
scss/bs4/bs4-jf.scss Normal file
View File

@@ -0,0 +1,140 @@
$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;
}
.icon-button {
color: $text-muted;
}
.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;
}

140
scss/bs5/bs5-jf.scss Normal file
View File

@@ -0,0 +1,140 @@
$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;
}
.icon-button:active {
color: $text-muted;
}
.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;
}

35
scss/compile.py Executable file
View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import sass
import subprocess
import shutil
from pathlib import Path
def runcmd(cmd):
proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
return proc.communicate()
local_path = Path(__file__).resolve().parent
node_bin = local_path.parent / 'node_modules' / '.bin'
for bsv in [d for d in local_path.iterdir() if 'bs' in d.name]:
scss = bsv / f'{bsv.name}-jf.scss'
css = bsv / f'{bsv.name}-jf.css'
min_css = bsv.parents[1] / 'jellyfin_accounts' / 'data' / 'static' / f'{bsv.name}-jf.css'
with open(css, 'w') as f:
f.write(sass.compile(filename=str(scss.resolve()),
output_style='expanded',
precision=6))
if css.exists():
print(f'{bsv.name}: Compiled.')
runcmd(f'{str((node_bin / "postcss").resolve())} {str(css.resolve())} --replace --use autoprefixer')
print(f'{bsv.name}: Prefixed.')
runcmd(f'{str((node_bin / "cleancss").resolve())} --level 1 --format breakWith=lf --output {str(min_css.resolve())} {str(css.resolve())}')
if min_css.exists():
print(f'{bsv.name}: Minified and copied to {str(min_css.resolve())}.')
for v in [('bootstrap', 'bs5'), ('bootstrap4', 'bs4')]:
new_path = str((local_path.parent / 'jellyfin_accounts' / 'data' / 'static' / (v[1] + '.css')).resolve())
shutil.copy(str((local_path.parent / 'node_modules' / v[0] / 'dist' / 'css' / 'bootstrap.min.css').resolve()),
new_path)
print(f'Copied {v[1]} to {new_path}')

17
scss/get_node_deps.py Normal file
View File

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