mirror of
https://github.com/hrfee/jellyfin-accounts.git
synced 2025-12-19 16:51:13 +00:00
Compare commits
60 Commits
modular-st
...
v0.3.7
| Author | SHA1 | Date | |
|---|---|---|---|
| b5af2e7f9d | |||
| dea613fa85 | |||
| b8fdb64f68 | |||
| e80b233af2 | |||
| 3e53bcab27 | |||
| 2551307877 | |||
| 290e6b3dca | |||
| a49b4d9027 | |||
| d615b21c7d | |||
| 9afbd31faa | |||
| 27169e4e0d | |||
| db3b992857 | |||
| 89c132e92e | |||
| 7bda2f4141 | |||
| 71f05f2348 | |||
| 94e69ad090 | |||
| a3d3d97b3b | |||
| 781306f1ef | |||
| a62eab9565 | |||
| a2a2abc7f2 | |||
| fa0527c6a7 | |||
| b33922059c | |||
| 9da3832e3a | |||
| dcdb02f9db | |||
| 4be88c4670 | |||
| 4fb99d1724 | |||
| adef32ef89 | |||
| ade935da4e | |||
| 81bb2520ad | |||
| acad3b1853 | |||
| d1cd83f5ff | |||
| 061fdd65bb | |||
| 34e58f5cb2 | |||
| fe12b7c4be | |||
| ac500e14cd | |||
| ac60cc37da | |||
| 46751e3743 | |||
| 0bb54d1c45 | |||
| 8e94f04d5a | |||
| eb8e04d5a2 | |||
| 52a11c3905 | |||
| 52f9b5c963 | |||
| 55d26b541a | |||
| 4606415a38 | |||
| 00ba11940a | |||
| 9532f24e7a | |||
|
|
95e5d8fb3d | ||
|
|
3f1b2ad4a8 | ||
| b775e36171 | |||
| 68a459023c | |||
| 09bbe8fddf | |||
| 99c34d7916 | |||
| db1b707ec1 | |||
| 4809331502 | |||
| 24045034c8 | |||
| 079dff8d9f | |||
| b943bd1f27 | |||
| 267a47087c | |||
| 4d6872dc17 | |||
| 4372c9d12f |
25
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Template for bug reports.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
|
||||
Describe the problem, and what you would expect if it isn't clear already.
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
What to do to reproduce the problem.
|
||||
|
||||
**Logs**
|
||||
|
||||
When you notice the problem, check the output of `jf-accounts`. If the problem is not obvious (e.g an expection or 'ERROR' log), try enabling `debug` in your configuration's `[ui]` section, restarting and reproducing the problem. You should then take a screenshot of the output, or paste it here, preferably between \`\`\` tags (e.g \`\`\``Log here`\`\`\`).
|
||||
If nothing catches your eye in the log, access the admin page via your browser, go into the console (Right click > Inspect Element > Console), refresh, then paste the output here in the same way as above.
|
||||
|
||||
**Platform**
|
||||
|
||||
Include the platform jf-accounts is running on (e.g Windows, Linux, Docker), the python version, and if necessary the browser version and platform.
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -4,11 +4,20 @@ 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*
|
||||
mail/*.html
|
||||
jellyfin_accounts/data/*.html
|
||||
|
||||
152
README.md
152
README.md
@@ -1,20 +1,20 @@
|
||||
# 
|
||||
# 
|
||||
|
||||
A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin).
|
||||
* Provides a web interface for creating invite codes, and a simple account creation form
|
||||
* Provides a web interface for creating/sending invites
|
||||
* Sends out emails when a user requests a password reset
|
||||
* Uses a basic python jellyfin API client for communication with the server.
|
||||
* Uses [Flask](https://github.com/pallets/flask), [HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth), [itsdangerous](https://github.com/pallets/itsdangerous), and [Waitress](https://github.com/Pylons/waitress)
|
||||
* Frontend uses [Bootstrap](https://getbootstrap.com), [jQuery](https://jquery.com) and [jQuery-serialize-object](https://github.com/macek/jquery-serialize-object)
|
||||
* Frontend uses [Bootstrap](https://v5.getbootstrap.com)
|
||||
* Password resets are handled using smtplib, requests, and [jinja](https://github.com/pallets/jinja)
|
||||
## Interface
|
||||
<p align="center">
|
||||
<img src="images/jfa.gif" width="100%"></img>
|
||||
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/jfa.gif" width="100%"></img>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="images/admin.png" width="48%" style="margin-right: 1.5%;" alt="Admin page"></img>
|
||||
<img src="images/create.png" width="48%" style="margin-left: 1.5%;" alt="Account creation page"></img>
|
||||
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/admin.png" width="48%" style="margin-right: 1.5%;" alt="Admin page"></img>
|
||||
<img src="https://raw.githubusercontent.com/hrfee/jellyfin-accounts/main/images/create.png" width="48%" style="margin-left: 1.5%;" alt="Account creation page"></img>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -31,26 +31,22 @@ A basic account management system for [Jellyfin](https://github.com/jellyfin/jel
|
||||
* requests
|
||||
* itsdangerous
|
||||
* passlib
|
||||
* secrets
|
||||
* configparser
|
||||
* pyOpenSSL
|
||||
* waitress
|
||||
* pytz
|
||||
* python-dateutil
|
||||
* watchdog
|
||||
* packaging
|
||||
```
|
||||
### Install
|
||||
|
||||
Usually as simple as:
|
||||
```
|
||||
git clone https://github.com/hrfee/jellyfin-accounts.git
|
||||
cd jellyfin-accounts
|
||||
pip3 install pyOpenSSL
|
||||
python3 setup.py install
|
||||
pip install jellyfin-accounts
|
||||
```
|
||||
If not, or if you want to use docker, see [install](https://github.com/hrfee/jellyfin-accounts/wiki/Install).
|
||||
|
||||
### Usage
|
||||
## Usage
|
||||
* Passing no arguments will run the server
|
||||
```
|
||||
usage: jf-accounts [-h] [-c CONFIG] [-d DATA] [--host HOST] [-p PORT] [-g]
|
||||
@@ -69,142 +65,26 @@ optional arguments:
|
||||
and homescreen layout and output it as json to be used
|
||||
as a user template.
|
||||
```
|
||||
### Setup
|
||||
## Setup
|
||||
#### New user template
|
||||
* You may want to restrict a user from accessing certain libraries (e.g 4K Movies), display their account on the login screen by default, or set a default homecrseen layout. Jellyfin stores these settings in the user's policy, configuration and displayPreferences.
|
||||
* Make a temporary account and change its settings, then run `jf-accounts --get_defaults`. Choose your user, and this data will be stored at the location you set in `user_template`, `user_configuration` and `user_displayprefs` (or their default locations), and used for all subsequent new accounts.
|
||||
* Make a temporary account and configure it, then in the web UI, go into "Settings => Set new account defaults". Choose the account, and its configuration will be stored for future use.
|
||||
#### Emails/Password Resets
|
||||
* When someone initiates forget password on Jellyfin, a file named `passwordreset*.json` is created in its configuration directory. This directory is monitored and when created, the program reads the username, expiry time and PIN, puts it into a template and sends it to whatever address is specified in `emails.json`.
|
||||
* **The default forget password popup references the `passwordreset*.json` file created. This is confusing for users, so a quick fix is to edit the `MessageForgotPasswordFileCreated` string in Jellyfin's language folder.**
|
||||
* Currently, jellyfin-accounts supports generic SSL/TLS or STARTTLS secured SMTP, and the [mailgun](https://mailgun.com) REST API.
|
||||
* Email html is created using [mjml](https://mjml.io), and [jinja](https://github.com/pallets/jinja) templating is used. If you wish to create your own, ensure you use the same jinja expressions (`{{ pin }}`, etc.) as used in `data/email.mjml` or `invite-email.mjml`, and also create plain text versions for legacy email clients.
|
||||
|
||||
#### Configuration
|
||||
### Configuration
|
||||
* Note: Make sure to put this behind a reverse proxy with HTTPS.
|
||||
|
||||
On first run, access the setup wizard at `0.0.0.0:8056`. When finished, restart the program.
|
||||
|
||||
The configuration is stored at `~/.jf-accounts/config.ini`.
|
||||
The configuration is stored at `~/.jf-accounts/config.ini`. Settings can be changed through the web UI, or by manually editing the file.
|
||||
|
||||
For detailed descriptions of each setting, see [setup](https://github.com/hrfee/jellyfin-accounts/wiki/Setup).
|
||||
|
||||
### Donations
|
||||
I strongly suggest you send your money to [Jellyfin](https://opencollective.com/jellyfin) or a good charity, but for those who want to help me out, a Paypal link is below.
|
||||
|
||||
```
|
||||
[jellyfin]
|
||||
; It is reccommended to create a limited admin account for this program.
|
||||
username = username
|
||||
password = password
|
||||
; Jellyfin server address. Can be public, or local for security purposes.
|
||||
server = http://jellyfin.local:8096
|
||||
; Publicly accessible Jellyfin address, used on invite form.
|
||||
; Leave blank to use the same address as above.
|
||||
public_server = https://jellyf.in:443
|
||||
client = jf-accounts
|
||||
version = 0.1
|
||||
device = jf-accounts
|
||||
device_id = jf-accounts-0.1
|
||||
|
||||
[ui]
|
||||
; Set 0.0.0.0 to run localhost
|
||||
host = 0.0.0.0
|
||||
port = 8056
|
||||
; Enable this to use Jellyfin users instead of the below username and pw.
|
||||
jellyfin_login = true
|
||||
; Allows only admin users on Jellyfin to access admin page.
|
||||
admin_only = true
|
||||
; Username to use on admin page... (leave blank if using jellyfin_login)
|
||||
username = your username
|
||||
; ..and its corresponding password (leave blank if using jellyfin_login)
|
||||
password = your password
|
||||
|
||||
debug = false
|
||||
|
||||
; Displayed at the bottom of all pages except admin
|
||||
contact_message = Need help? contact me.
|
||||
; Displayed at top of form page.
|
||||
help_message = Enter your details to create an account.
|
||||
; Displayed when an account is created.
|
||||
success_message = Your account has been created. Click below to continue to Jellyfin.
|
||||
|
||||
[password_validation]
|
||||
; Enables password validation.
|
||||
enabled = true
|
||||
; Min. password length
|
||||
min_length = 8
|
||||
; Min. number of uppercase characters
|
||||
upper = 1
|
||||
; Min. number of lowercase characters
|
||||
lower = 0
|
||||
; Min. number of numbers
|
||||
number = 1
|
||||
; Min. number of special characters
|
||||
special = 0
|
||||
|
||||
[email]
|
||||
; Leave this whole section if you aren't using any email-related features.
|
||||
use_24h = true
|
||||
; Date format follows datetime's strftime.
|
||||
date_format = %d/%m/%y
|
||||
; Displayed at bottom of emails
|
||||
message = Need help? contact me.
|
||||
; Mail methods: mailgun, smtp
|
||||
method = smtp
|
||||
; Address to send from
|
||||
address = jellyfin@jellyf.in
|
||||
; The name of the sender
|
||||
from = Jellyfin
|
||||
|
||||
[password_resets]
|
||||
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
|
||||
enabled = true
|
||||
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
|
||||
watch_directory = /path/to/jellyfin
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_html =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_text =
|
||||
; Subject of emails
|
||||
subject = Password Reset - Jellyfin
|
||||
|
||||
[invite_emails]
|
||||
; If enabled, allows one to send an invite directly to an email address.
|
||||
enabled = true
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_html =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_text =
|
||||
subject = Invite - Jellyfin
|
||||
; Base url for jf-accounts. This necessary because most will use a reverse proxy, so the program has no other way of knowing what URL to send.
|
||||
url_base = http://accounts.jellyf.in:8056/invite
|
||||
|
||||
[mailgun]
|
||||
|
||||
api_url = https://api.mailgun.net...
|
||||
api_key = your api key
|
||||
|
||||
[smtp]
|
||||
; Choose between ssl_tls and starttls. Your provider should tell you which to use, but generally SSL/TLS is 465, STARTTLS 587
|
||||
encryption = starttls
|
||||
server = smtp.jellyf.in
|
||||
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
|
||||
port = 587
|
||||
password = smtp password
|
||||
|
||||
[files]
|
||||
; When the below paths are left blank, files are stored in ~/.jf-accounts/.
|
||||
|
||||
; Path to store valid invites.
|
||||
invites =
|
||||
; Path to store emails addresses in JSON
|
||||
emails =
|
||||
; Path to the user policy template. Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_template =
|
||||
; Path to the user configuration template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_configuration =
|
||||
; Path to the user display preferences template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_displayprefs =
|
||||
; Path to custom bootstrap.css
|
||||
custom_css =
|
||||
```
|
||||
|
||||
|
||||
[Donate](https://www.paypal.me/hrfee)
|
||||
|
||||
134
config-default.ini
Normal file
134
config-default.ini
Normal file
@@ -0,0 +1,134 @@
|
||||
[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.7
|
||||
device = jf-accounts
|
||||
device_id = jf-accounts-0.3.7
|
||||
|
||||
[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
|
||||
; address to send notifications to (leave blank if using jellyfin_login)
|
||||
email = example@example.com
|
||||
debug = false
|
||||
; displayed at bottom of all pages except admin
|
||||
contact_message = Need help? contact me.
|
||||
; displayed 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
|
||||
|
||||
[notifications]
|
||||
; notification related settings.
|
||||
; enabling adds optional toggles to invites to notify on expiry and user creation.
|
||||
enabled = true
|
||||
; path to expiry notification email html.
|
||||
expiry_html =
|
||||
; path to expiry notification email in plaintext.
|
||||
expiry_text =
|
||||
; path to user creation notification email html.
|
||||
created_html =
|
||||
; path to user creation notification email in plaintext.
|
||||
created_text =
|
||||
|
||||
[mailgun]
|
||||
; mailgun 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 =
|
||||
|
||||
BIN
images/admin.png
BIN
images/admin.png
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 |
BIN
images/jfa.gif
BIN
images/jfa.gif
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 6.7 MiB |
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
__version__ = "0.2"
|
||||
# Runs it!
|
||||
__version__ = "0.3.7"
|
||||
|
||||
import secrets
|
||||
import configparser
|
||||
@@ -11,194 +11,242 @@ 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
|
||||
from jellyfin_accounts.config import Config
|
||||
|
||||
parser = argparse.ArgumentParser(description="jellyfin-accounts")
|
||||
|
||||
parser.add_argument("-c", "--config",
|
||||
help="specifies path to configuration file.")
|
||||
parser.add_argument("-d", "--data",
|
||||
help=("specifies directory to store data in. " +
|
||||
"defaults to ~/.jf-accounts."))
|
||||
parser.add_argument("--host",
|
||||
help="address to host web ui on.")
|
||||
parser.add_argument("-p", "--port",
|
||||
help="port to host web ui on.")
|
||||
parser.add_argument("-g", "--get_defaults",
|
||||
help=("tool to grab a JF users " +
|
||||
"policy (access, perms, etc.) and " +
|
||||
"homescreen layout and " +
|
||||
"output it as json to be used as a user template."),
|
||||
action='store_true')
|
||||
parser.add_argument("-c", "--config", help="specifies path to configuration file.")
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--data",
|
||||
help=("specifies directory to store data in. " + "defaults to ~/.jf-accounts."),
|
||||
)
|
||||
parser.add_argument("--host", help="address to host web ui on.")
|
||||
parser.add_argument("-p", "--port", help="port to host web ui on.")
|
||||
parser.add_argument(
|
||||
"-g",
|
||||
"--get_defaults",
|
||||
help=(
|
||||
"tool to grab a JF users "
|
||||
+ "policy (access, perms, etc.) and "
|
||||
+ "homescreen layout and "
|
||||
+ "output it as json to be used as a user template."
|
||||
),
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i", "--install", help="attempt to install a system service.", action="store_true"
|
||||
)
|
||||
|
||||
args, leftovers = parser.parse_known_args()
|
||||
|
||||
if args.data is not None:
|
||||
data_dir = Path(args.data)
|
||||
else:
|
||||
data_dir = Path.home() / '.jf-accounts'
|
||||
data_dir = Path.home() / ".jf-accounts"
|
||||
|
||||
local_dir = (Path(__file__).parent / 'data').resolve()
|
||||
local_dir = (Path(__file__).parent / "data").resolve()
|
||||
config_base_path = local_dir / "config-base.json"
|
||||
|
||||
first_run = False
|
||||
if data_dir.exists() is False or (data_dir / 'config.ini').exists() is False:
|
||||
if data_dir.exists() is False or (data_dir / "config.ini").exists() is False:
|
||||
if not data_dir.exists():
|
||||
Path.mkdir(data_dir)
|
||||
print(f'Config dir not found, so created at {str(data_dir)}')
|
||||
print(f"Config dir not found, so generating at {str(data_dir)}")
|
||||
if args.config is None:
|
||||
config_path = data_dir / 'config.ini'
|
||||
shutil.copy(str(local_dir / 'config-default.ini'),
|
||||
str(config_path))
|
||||
config_path = data_dir / "config.ini"
|
||||
from jellyfin_accounts.generate_ini import generate_ini
|
||||
|
||||
default_path = local_dir / "config-default.ini"
|
||||
generate_ini(config_base_path, default_path, __version__)
|
||||
shutil.copy(str(default_path), str(config_path))
|
||||
print("Setup through the web UI, or quit and edit the configuration manually.")
|
||||
first_run = True
|
||||
else:
|
||||
config_path = Path(args.config)
|
||||
print(f'config.ini can be found at {str(config_path)}')
|
||||
print(f"config.ini can be found at {str(config_path)}")
|
||||
else:
|
||||
config_path = data_dir / 'config.ini'
|
||||
config_path = data_dir / "config.ini"
|
||||
|
||||
# Temp config so logger knows whether to use debug mode or not
|
||||
temp_config = configparser.RawConfigParser()
|
||||
temp_config.read(config_path)
|
||||
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
def create_log(name):
|
||||
log = logging.getLogger(name)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
if config.getboolean('ui', 'debug'):
|
||||
if temp_config.getboolean("ui", "debug"):
|
||||
log.setLevel(logging.DEBUG)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
else:
|
||||
log.setLevel(logging.INFO)
|
||||
handler.setLevel(logging.INFO)
|
||||
fmt = ' %(name)s - %(levelname)s - %(message)s'
|
||||
fmt = " %(name)s - %(levelname)s - %(message)s"
|
||||
format = logging.Formatter(fmt)
|
||||
handler.setFormatter(format)
|
||||
log.addHandler(handler)
|
||||
log.propagate = False
|
||||
return log
|
||||
|
||||
log = create_log('main')
|
||||
web_log = create_log('waitress')
|
||||
|
||||
log = create_log("main")
|
||||
|
||||
config = Config(config_path, secrets.token_urlsafe(16), data_dir, local_dir, log)
|
||||
|
||||
web_log = create_log("waitress")
|
||||
if not first_run:
|
||||
email_log = create_log('emails')
|
||||
auth_log = create_log('auth')
|
||||
email_log = create_log("emails")
|
||||
auth_log = create_log("auth")
|
||||
|
||||
if args.host is not None:
|
||||
log.debug(f'Using specified host {args.host}')
|
||||
config['ui']['host'] = args.host
|
||||
log.debug(f"Using specified host {args.host}")
|
||||
config["ui"]["host"] = args.host
|
||||
if args.port is not None:
|
||||
log.debug(f'Using specified port {args.port}')
|
||||
config['ui']['port'] = args.port
|
||||
|
||||
for key in config['files']:
|
||||
if config['files'][key] == '':
|
||||
if key != 'custom_css':
|
||||
log.debug(f'Using default {key}')
|
||||
config['files'][key] = str(data_dir / (key + '.json'))
|
||||
|
||||
for key in ['user_configuration', 'user_displayprefs']:
|
||||
if key not in config['files']:
|
||||
log.debug(f'Using default {key}')
|
||||
config['files'][key] = str(data_dir / (key + '.json'))
|
||||
|
||||
with open(config['files']['invites'], 'r') as f:
|
||||
temp_invites = json.load(f)
|
||||
if 'invites' in temp_invites:
|
||||
new_invites = {}
|
||||
log.info('Converting invites.json to new format, temporary.')
|
||||
for el in temp_invites['invites']:
|
||||
i = {'valid_till': el['valid_till']}
|
||||
if 'email' in el:
|
||||
i['email'] = el['email']
|
||||
new_invites[el['code']] = i
|
||||
with open(config['files']['invites'], 'w') as f:
|
||||
f.write(json.dumps(new_invites, indent=4, default=str))
|
||||
log.debug(f"Using specified port {args.port}")
|
||||
config["ui"]["port"] = args.port
|
||||
|
||||
|
||||
data_store = JSONStorage(config['files']['emails'],
|
||||
config['files']['invites'],
|
||||
config['files']['user_template'],
|
||||
config['files']['user_displayprefs'],
|
||||
config['files']['user_configuration'])
|
||||
try:
|
||||
with open(config["files"]["invites"], "r") as f:
|
||||
temp_invites = json.load(f)
|
||||
if "invites" in temp_invites:
|
||||
new_invites = {}
|
||||
log.info("Converting invites.json to new format, temporary.")
|
||||
for el in temp_invites["invites"]:
|
||||
i = {"valid_till": el["valid_till"]}
|
||||
if "email" in el:
|
||||
i["email"] = el["email"]
|
||||
new_invites[el["code"]] = i
|
||||
with open(config["files"]["invites"], "w") as f:
|
||||
f.write(json.dumps(new_invites, indent=4, default=str))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def default_css():
|
||||
css = {}
|
||||
css['href'] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
|
||||
css['integrity'] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
|
||||
css['crossorigin'] = "anonymous"
|
||||
return css
|
||||
data_store = JSONStorage(
|
||||
config["files"]["emails"],
|
||||
config["files"]["invites"],
|
||||
config["files"]["user_template"],
|
||||
config["files"]["user_displayprefs"],
|
||||
config["files"]["user_configuration"],
|
||||
)
|
||||
|
||||
if config.getboolean("ui", "bs5"):
|
||||
css_file = "bs5-jf.css"
|
||||
log.debug("Using Bootstrap 5")
|
||||
else:
|
||||
css_file = "bs4-jf.css"
|
||||
|
||||
|
||||
css = {}
|
||||
css = default_css()
|
||||
if 'custom_css' in config['files']:
|
||||
if config['files']['custom_css'] != '':
|
||||
with open(config_base_path, "r") as f:
|
||||
themes = json.load(f)["ui"]["theme"]
|
||||
|
||||
theme_options = themes["options"]
|
||||
|
||||
if "theme" not in config["ui"] or config["ui"]["theme"] not in theme_options:
|
||||
config["ui"]["theme"] = themes["value"]
|
||||
|
||||
if config.getboolean("ui", "bs5"):
|
||||
num = 5
|
||||
else:
|
||||
num = 4
|
||||
|
||||
current_theme = config["ui"]["theme"]
|
||||
|
||||
if "Bootstrap" in current_theme:
|
||||
css_file = f"bs{num}.css"
|
||||
elif "Jellyfin" in current_theme:
|
||||
css_file = f"bs{num}-jf.css"
|
||||
elif "Custom" in current_theme and "custom_css" in config["files"]:
|
||||
if config["files"]["custom_css"] != "":
|
||||
try:
|
||||
shutil.copy(config['files']['custom_css'],
|
||||
(local_dir / 'static' / 'bootstrap.css'))
|
||||
log.debug('Loaded custom CSS')
|
||||
css['href'] = '/bootstrap.css'
|
||||
css['integrity'] = ''
|
||||
css['crossorigin'] = ''
|
||||
css_path = Path(config["files"]["custom_css"])
|
||||
shutil.copy(css_path, (local_dir / "static" / css_path.name))
|
||||
log.debug(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.')
|
||||
log.error(
|
||||
f'Custom CSS {config["files"]["custom_css"]} not found, using default.'
|
||||
)
|
||||
|
||||
|
||||
if ('email_html' not in config['password_resets'] or
|
||||
config['password_resets']['email_html'] == ''):
|
||||
log.debug('Using default password reset email HTML template')
|
||||
config['password_resets']['email_html'] = str(local_dir / 'email.html')
|
||||
if ('email_text' not in config['password_resets'] or
|
||||
config['password_resets']['email_text'] == ''):
|
||||
log.debug('Using default password reset email plaintext template')
|
||||
config['password_resets']['email_text'] = str(local_dir / 'email.txt')
|
||||
|
||||
if ('email_html' not in config['invite_emails'] or
|
||||
config['invite_emails']['email_html'] == ''):
|
||||
log.debug('Using default invite email HTML template')
|
||||
config['invite_emails']['email_html'] = str(local_dir /
|
||||
'invite-email.html')
|
||||
if ('email_text' not in config['invite_emails'] or
|
||||
config['invite_emails']['email_text'] == ''):
|
||||
log.debug('Using default invite email plaintext template')
|
||||
config['invite_emails']['email_text'] = str(local_dir /
|
||||
'invite-email.txt')
|
||||
if ('public_server' not in config['jellyfin'] or
|
||||
config['jellyfin']['public_server'] == ''):
|
||||
config['jellyfin']['public_server'] = config['jellyfin']['server']
|
||||
def resp(success=True, code=500):
|
||||
if success:
|
||||
r = jsonify({"success": True})
|
||||
if code == 500:
|
||||
r.status_code = 200
|
||||
else:
|
||||
r.status_code = code
|
||||
else:
|
||||
r = jsonify({"success": False})
|
||||
r.status_code = code
|
||||
return r
|
||||
|
||||
|
||||
def main():
|
||||
if args.get_defaults:
|
||||
if args.install:
|
||||
executable = sys.argv[0]
|
||||
print(f'Assuming executable path "{executable}".')
|
||||
options = ["systemd"]
|
||||
for i, opt in enumerate(options):
|
||||
print(f"{i+1}: {opt}")
|
||||
success = False
|
||||
while not success:
|
||||
try:
|
||||
method = options[int(input(">: ")) - 1]
|
||||
success = True
|
||||
except IndexError:
|
||||
pass
|
||||
if method == "systemd":
|
||||
with open(local_dir / "services" / "jf-accounts.service", "r") as f:
|
||||
data = f.read()
|
||||
data = data.replace("{executable}", executable)
|
||||
service_path = str(Path("jf-accounts.service").resolve())
|
||||
with open(service_path, "w") as f:
|
||||
f.write(data)
|
||||
print(f"service written to the current directory\n({service_path}).")
|
||||
print("Place this in the appropriate directory, and reload daemons.")
|
||||
elif args.get_defaults:
|
||||
import json
|
||||
from jellyfin_accounts.jf_api import Jellyfin
|
||||
jf = Jellyfin(config['jellyfin']['server'],
|
||||
config['jellyfin']['client'],
|
||||
config['jellyfin']['version'],
|
||||
config['jellyfin']['device'],
|
||||
config['jellyfin']['device_id'])
|
||||
|
||||
jf = Jellyfin(
|
||||
config["jellyfin"]["server"],
|
||||
config["jellyfin"]["client"],
|
||||
config["jellyfin"]["version"],
|
||||
config["jellyfin"]["device"],
|
||||
config["jellyfin"]["device_id"],
|
||||
)
|
||||
print("NOTE: This can now be done through the web ui.")
|
||||
print("""
|
||||
print(
|
||||
"""
|
||||
This tool lets you grab various settings from a user,
|
||||
so that they can be applied every time a new account is
|
||||
created. """)
|
||||
created. """
|
||||
)
|
||||
print("Step 1: User Policy.")
|
||||
print("""
|
||||
A user policy stores a users permissions (e.g access rights and
|
||||
print(
|
||||
"""
|
||||
A user policy stores a users permissions (e.g access rights and
|
||||
most of the other settings in the 'Profile' and 'Access' tabs
|
||||
of a user). """)
|
||||
of a user). """
|
||||
)
|
||||
success = False
|
||||
msg = "Get public users only or all users? (requires auth) [public/all]: "
|
||||
msg = "Get public users only or all users? (requires auth) [public/all]: "
|
||||
public = False
|
||||
while not success:
|
||||
choice = input(msg)
|
||||
if choice == 'public':
|
||||
if choice == "public":
|
||||
public = True
|
||||
print("Make sure the user is publicly visible!")
|
||||
success = True
|
||||
elif choice == 'all':
|
||||
jf.authenticate(config['jellyfin']['username'],
|
||||
config['jellyfin']['password'])
|
||||
elif choice == "all":
|
||||
jf.authenticate(
|
||||
config["jellyfin"]["username"], config["jellyfin"]["password"]
|
||||
)
|
||||
public = False
|
||||
success = True
|
||||
users = jf.getUsers(public=public)
|
||||
@@ -207,69 +255,88 @@ def main():
|
||||
success = False
|
||||
while not success:
|
||||
try:
|
||||
user_index = int(input(">: "))-1
|
||||
policy = users[user_index]['Policy']
|
||||
user_index = int(input(">: ")) - 1
|
||||
policy = users[user_index]["Policy"]
|
||||
success = True
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
data_store.user_template = policy
|
||||
print(f'Policy written to "{config["files"]["user_template"]}".')
|
||||
print('In future, this policy will be copied to all new users.')
|
||||
print('Step 2: Homescreen Layout')
|
||||
print("""
|
||||
print("In future, this policy will be copied to all new users.")
|
||||
print("Step 2: Homescreen Layout")
|
||||
print(
|
||||
"""
|
||||
You may want to customize the default layout of a new user's
|
||||
home screen. These settings can be applied to an account through
|
||||
the 'Home' section in a user's settings. """)
|
||||
the 'Home' section in a user's settings. """
|
||||
)
|
||||
success = False
|
||||
while not success:
|
||||
choice = input("Grab the chosen user's homescreen layout? [y/n]: ")
|
||||
if choice.lower() == 'y':
|
||||
user_id = users[user_index]['Id']
|
||||
configuration = users[user_index]['Configuration']
|
||||
if choice.lower() == "y":
|
||||
user_id = users[user_index]["Id"]
|
||||
configuration = users[user_index]["Configuration"]
|
||||
display_prefs = jf.getDisplayPreferences(user_id)
|
||||
data_store.user_configuration = configuration
|
||||
print(f'Configuration written to "{config["files"]["user_configuration"]}".')
|
||||
print(
|
||||
f'Configuration written to "{config["files"]["user_configuration"]}".'
|
||||
)
|
||||
data_store.user_displayprefs = display_prefs
|
||||
print(f'Display Prefs written to "{config["files"]["user_displayprefs"]}".')
|
||||
print(
|
||||
f'Display Prefs written to "{config["files"]["user_displayprefs"]}".'
|
||||
)
|
||||
success = True
|
||||
elif choice.lower() == 'n':
|
||||
elif choice.lower() == "n":
|
||||
success = True
|
||||
|
||||
else:
|
||||
def signal_handler(sig, frame):
|
||||
print('Quitting...')
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
global app
|
||||
app = Flask(__name__, root_path=str(local_dir))
|
||||
app.config['DEBUG'] = config.getboolean('ui', 'debug')
|
||||
app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
|
||||
app.config["DEBUG"] = config.getboolean("ui", "debug")
|
||||
app.config["SECRET_KEY"] = secrets.token_urlsafe(16)
|
||||
app.config["JSON_SORT_KEYS"] = False
|
||||
|
||||
from waitress import serve
|
||||
|
||||
if first_run:
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print("Quitting...")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
import jellyfin_accounts.setup
|
||||
host = config['ui']['host']
|
||||
port = config['ui']['port']
|
||||
log.info('Starting web UI for first run setup...')
|
||||
serve(app,
|
||||
host=host,
|
||||
port=port)
|
||||
|
||||
host = config["ui"]["host"]
|
||||
port = config["ui"]["port"]
|
||||
log.info("Starting web UI for first run setup...")
|
||||
serve(app, host=host, port=port)
|
||||
else:
|
||||
import jellyfin_accounts.web_api
|
||||
import jellyfin_accounts.web
|
||||
host = config['ui']['host']
|
||||
port = config['ui']['port']
|
||||
log.info(f'Starting web UI on {host}:{port}')
|
||||
if config.getboolean('password_resets', 'enabled'):
|
||||
import jellyfin_accounts.invite_daemon
|
||||
|
||||
host = config["ui"]["host"]
|
||||
port = config["ui"]["port"]
|
||||
log.info(f"Starting web UI on {host}:{port}")
|
||||
if config.getboolean("password_resets", "enabled"):
|
||||
|
||||
def start_pwr():
|
||||
import jellyfin_accounts.pw_reset
|
||||
|
||||
jellyfin_accounts.pw_reset.start()
|
||||
|
||||
pwr = threading.Thread(target=start_pwr, daemon=True)
|
||||
log.info('Starting email thread')
|
||||
log.info("Starting email thread")
|
||||
pwr.start()
|
||||
|
||||
serve(app,
|
||||
host=host,
|
||||
port=int(port))
|
||||
def signal_handler(sig, frame):
|
||||
print("Quitting...")
|
||||
if config.getboolean("notifications", "enabled"):
|
||||
jellyfin_accounts.invite_daemon.inviteDaemon.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
serve(app, host=host, port=int(port))
|
||||
|
||||
121
jellyfin_accounts/config.py
Normal file
121
jellyfin_accounts/config.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
import configparser
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Config:
|
||||
"""
|
||||
Configuration object that can automatically reload modified settings.
|
||||
Behaves mostly like a dictionary.
|
||||
:param file: Path to config.ini, where parameters are set.
|
||||
:param instance: Used to identify specific jf-accounts instances in environment variables.
|
||||
:param data_dir: Path to directory with config, invites, templates, etc.
|
||||
:param local_dir: Path to internally stored config base, emails, etc.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def load_config(config_path, data_dir, local_dir, log):
|
||||
# Lord forgive me for this mess
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(config_path)
|
||||
for key in config["files"]:
|
||||
if config["files"][key] == "":
|
||||
if key != "custom_css":
|
||||
log.debug(f"Using default {key}")
|
||||
config["files"][key] = str(data_dir / (key + ".json"))
|
||||
|
||||
for key in ["user_configuration", "user_displayprefs"]:
|
||||
if key not in config["files"]:
|
||||
log.debug(f"Using default {key}")
|
||||
config["files"][key] = str(data_dir / (key + ".json"))
|
||||
|
||||
if "no_username" not in config["email"]:
|
||||
config["email"]["no_username"] = "false"
|
||||
log.debug("Set no_username to false")
|
||||
if (
|
||||
"email_html" not in config["password_resets"]
|
||||
or config["password_resets"]["email_html"] == ""
|
||||
):
|
||||
log.debug("Using default password reset email HTML template")
|
||||
config["password_resets"]["email_html"] = str(local_dir / "email.html")
|
||||
if (
|
||||
"email_text" not in config["password_resets"]
|
||||
or config["password_resets"]["email_text"] == ""
|
||||
):
|
||||
log.debug("Using default password reset email plaintext template")
|
||||
config["password_resets"]["email_text"] = str(local_dir / "email.txt")
|
||||
|
||||
if (
|
||||
"email_html" not in config["invite_emails"]
|
||||
or config["invite_emails"]["email_html"] == ""
|
||||
):
|
||||
log.debug("Using default invite email HTML template")
|
||||
config["invite_emails"]["email_html"] = str(local_dir / "invite-email.html")
|
||||
if (
|
||||
"email_text" not in config["invite_emails"]
|
||||
or config["invite_emails"]["email_text"] == ""
|
||||
):
|
||||
log.debug("Using default invite email plaintext template")
|
||||
config["invite_emails"]["email_text"] = str(local_dir / "invite-email.txt")
|
||||
if (
|
||||
"public_server" not in config["jellyfin"]
|
||||
or config["jellyfin"]["public_server"] == ""
|
||||
):
|
||||
config["jellyfin"]["public_server"] = config["jellyfin"]["server"]
|
||||
if "bs5" not in config["ui"] or config["ui"]["bs5"] == "":
|
||||
config["ui"]["bs5"] = "false"
|
||||
if (
|
||||
"expiry_html" not in config["notifications"]
|
||||
or config["notifications"]["expiry_html"] == ""
|
||||
):
|
||||
log.debug("Using default expiry notification HTML template")
|
||||
config["notifications"]["expiry_html"] = str(local_dir / "expired.html")
|
||||
if (
|
||||
"expiry_text" not in config["notifications"]
|
||||
or config["notifications"]["expiry_text"] == ""
|
||||
):
|
||||
log.debug("Using default expiry notification plaintext template")
|
||||
config["notifications"]["expiry_text"] = str(local_dir / "expired.txt")
|
||||
if (
|
||||
"created_html" not in config["notifications"]
|
||||
or config["notifications"]["created_html"] == ""
|
||||
):
|
||||
log.debug("Using default user creation notification HTML template")
|
||||
config["notifications"]["created_html"] = str(local_dir / "created.html")
|
||||
if (
|
||||
"created_text" not in config["notifications"]
|
||||
or config["notifications"]["created_text"] == ""
|
||||
):
|
||||
log.debug("Using default user creation notification plaintext template")
|
||||
config["notifications"]["created_text"] = str(local_dir / "created.txt")
|
||||
|
||||
return config
|
||||
|
||||
def __init__(self, file, instance, data_dir, local_dir, log):
|
||||
self.config_path = Path(file)
|
||||
self.data_dir = data_dir
|
||||
self.local_dir = local_dir
|
||||
self.instance = instance
|
||||
self.log = log
|
||||
self.varname = f"JFA_{self.instance}_RELOADCONFIG"
|
||||
os.environ[self.varname] = "true"
|
||||
|
||||
def __getitem__(self, key):
|
||||
if os.environ[self.varname] == "true":
|
||||
self.config = Config.load_config(
|
||||
self.config_path, self.data_dir, self.local_dir, self.log
|
||||
)
|
||||
os.environ[self.varname] = "false"
|
||||
return self.config.__getitem__(key)
|
||||
|
||||
def getboolean(self, sect, key):
|
||||
if os.environ[self.varname] == "true":
|
||||
self.config = Config.load_config(
|
||||
self.config_path, self.data_dir, self.local_dir, self.log
|
||||
)
|
||||
os.environ[self.varname] = "false"
|
||||
return self.config.getboolean(sect, key)
|
||||
|
||||
def trigger_reload(self):
|
||||
os.environ[self.varname] = "true"
|
||||
567
jellyfin_accounts/data/config-base.json
Normal file
567
jellyfin_accounts/data/config-base.json
Normal file
@@ -0,0 +1,567 @@
|
||||
{
|
||||
"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)"
|
||||
},
|
||||
"email": {
|
||||
"name": "Admin email address",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_false": "jellyfin_login",
|
||||
"type": "text",
|
||||
"value": "example@example.com",
|
||||
"description": "Address to send notifications to (Leave blank if using jellyfin_login)"
|
||||
},
|
||||
"debug": {
|
||||
"name": "Debug logging",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": false
|
||||
},
|
||||
"contact_message": {
|
||||
"name": "Contact message",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"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": false,
|
||||
"type": "text",
|
||||
"value": "Enter your details to create an account.",
|
||||
"description": "Displayed at top of invite form."
|
||||
},
|
||||
"success_message": {
|
||||
"name": "Success message",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"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": false,
|
||||
"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": false,
|
||||
"type": "bool",
|
||||
"value": true
|
||||
},
|
||||
"min_length": {
|
||||
"name": "Minimum Length",
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "8"
|
||||
},
|
||||
"upper": {
|
||||
"name": "Minimum uppercase characters",
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "1"
|
||||
},
|
||||
"lower": {
|
||||
"name": "Minimum lowercase characters",
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "0"
|
||||
},
|
||||
"number": {
|
||||
"name": "Minimum number count",
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "1"
|
||||
},
|
||||
"special": {
|
||||
"name": "Minimum number of special characters",
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "0"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"meta": {
|
||||
"name": "Email",
|
||||
"description": "General email settings. Ignore if not using email features."
|
||||
},
|
||||
"no_username": {
|
||||
"name": "Use email addresses as username",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "bool",
|
||||
"value": false,
|
||||
"description": "Use email address from invite form as username on Jellyfin."
|
||||
},
|
||||
"use_24h": {
|
||||
"name": "Use 24h time",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "bool",
|
||||
"value": true
|
||||
},
|
||||
"date_format": {
|
||||
"name": "Date format",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "text",
|
||||
"value": "%d/%m/%y",
|
||||
"description": "Date format used in emails. Follows datetime.strftime format."
|
||||
},
|
||||
"message": {
|
||||
"name": "Help message",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "text",
|
||||
"value": "Need help? contact me.",
|
||||
"description": "Message displayed at bottom of emails."
|
||||
},
|
||||
"method": {
|
||||
"name": "Email method",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "select",
|
||||
"options": [
|
||||
"smtp",
|
||||
"mailgun"
|
||||
],
|
||||
"value": "smtp",
|
||||
"description": "Method of sending email to use."
|
||||
},
|
||||
"address": {
|
||||
"name": "Sent from (address)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "email",
|
||||
"value": "jellyfin@jellyf.in",
|
||||
"description": "Address to send emails from"
|
||||
},
|
||||
"from": {
|
||||
"name": "Sent from (name)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "method",
|
||||
"type": "text",
|
||||
"value": "Jellyfin",
|
||||
"description": "The name of the sender"
|
||||
}
|
||||
},
|
||||
"password_resets": {
|
||||
"meta": {
|
||||
"name": "Password Resets",
|
||||
"description": "Settings for the password reset handler."
|
||||
},
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Enable to store provided email addresses, monitor Jellyfin directory for pw-resets, and send reset pins"
|
||||
},
|
||||
"watch_directory": {
|
||||
"name": "Jellyfin directory",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "/path/to/jellyfin",
|
||||
"description": "Path to the folder Jellyfin puts password-reset files."
|
||||
},
|
||||
"email_html": {
|
||||
"name": "Custom email (HTML)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email html"
|
||||
},
|
||||
"email_text": {
|
||||
"name": "Custom email (plaintext)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to custom email in plain text"
|
||||
},
|
||||
"subject": {
|
||||
"name": "Email subject",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "Password Reset - Jellyfin",
|
||||
"description": "Subject of password reset emails."
|
||||
}
|
||||
},
|
||||
"invite_emails": {
|
||||
"meta": {
|
||||
"name": "Invite emails",
|
||||
"description": "Settings for sending invites directly to users."
|
||||
},
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"meta": {
|
||||
"name": "Notifications",
|
||||
"description": "Notification related settings."
|
||||
},
|
||||
"enabled": {
|
||||
"name": "Enabled",
|
||||
"required": "false",
|
||||
"requires_restart": true,
|
||||
"type": "bool",
|
||||
"value": true,
|
||||
"description": "Enabling adds optional toggles to invites to notify on expiry and user creation."
|
||||
},
|
||||
"expiry_html": {
|
||||
"name": "Expiry email (HTML)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to expiry notification email HTML."
|
||||
},
|
||||
"expiry_text": {
|
||||
"name": "Expiry email (Plaintext)",
|
||||
"required": false,
|
||||
"requires_restart": "false",
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to expiry notification email in plaintext."
|
||||
},
|
||||
"created_html": {
|
||||
"name": "User created email (HTML)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to user creation notification email HTML."
|
||||
},
|
||||
"created_text": {
|
||||
"name": "User created email (Plaintext)",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"depends_true": "enabled",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Path to user creation notification email in plaintext."
|
||||
}
|
||||
},
|
||||
"mailgun": {
|
||||
"meta": {
|
||||
"name": "Mailgun (Email)",
|
||||
"description": "Mailgun API connection settings"
|
||||
},
|
||||
"api_url": {
|
||||
"name": "API URL",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "https://api.mailgun.net..."
|
||||
},
|
||||
"api_key": {
|
||||
"name": "API Key",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "your api key"
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"meta": {
|
||||
"name": "SMTP (Email)",
|
||||
"description": "SMTP Server connection settings."
|
||||
},
|
||||
"encryption": {
|
||||
"name": "Encryption Method",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "select",
|
||||
"options": [
|
||||
"ssl_tls",
|
||||
"starttls"
|
||||
],
|
||||
"value": "starttls",
|
||||
"description": "Your email provider should provide different ports for each encryption method. Generally 465 for ssl_tls, 587 for starttls."
|
||||
},
|
||||
"server": {
|
||||
"name": "Server address",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "text",
|
||||
"value": "smtp.jellyf.in",
|
||||
"description": "SMTP Server address."
|
||||
},
|
||||
"port": {
|
||||
"name": "Port",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "number",
|
||||
"value": 465
|
||||
},
|
||||
"password": {
|
||||
"name": "Password",
|
||||
"required": false,
|
||||
"requires_restart": false,
|
||||
"type": "password",
|
||||
"value": "smtp password"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"meta": {
|
||||
"name": "File Storage",
|
||||
"description": "Optional settings for changing storage locations."
|
||||
},
|
||||
"invites": {
|
||||
"name": "Invite Storage",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored invites (json)."
|
||||
},
|
||||
"emails": {
|
||||
"name": "Email Addresses",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored email addresses (json)."
|
||||
},
|
||||
"user_template": {
|
||||
"name": "User Template",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored user policy template (json)."
|
||||
},
|
||||
"user_configuration": {
|
||||
"name": "userConfiguration",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored user configuration template (used for setting homescreen layout) (json)"
|
||||
},
|
||||
"user_displayprefs": {
|
||||
"name": "displayPreferences",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of stored displayPreferences template (also used for homescreen layout) (json)"
|
||||
},
|
||||
"custom_css": {
|
||||
"name": "Custom CSS",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"description": "Location of custom bootstrap CSS."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
[jellyfin]
|
||||
; It is reccommended to create a limited admin account for this program.
|
||||
username = username
|
||||
password = password
|
||||
; Jellyfin server address. Can be public, or local for security purposes.
|
||||
server = http://jellyfin.local:8096
|
||||
; Publicly accessible Jellyfin address, used on invite form.
|
||||
; Leave blank to use the same address as above.
|
||||
public_server = https://jellyf.in:443
|
||||
client = jf-accounts
|
||||
version = 0.1
|
||||
device = jf-accounts
|
||||
device_id = jf-accounts-0.1
|
||||
|
||||
[ui]
|
||||
; Set 0.0.0.0 to run localhost
|
||||
host = 0.0.0.0
|
||||
port = 8056
|
||||
; Enable this to use Jellyfin users instead of the below username and pw.
|
||||
jellyfin_login = true
|
||||
; Allows only admin users on Jellyfin to access admin page.
|
||||
admin_only = true
|
||||
; Username to use on admin page... (leave blank if using jellyfin_login)
|
||||
username = your username
|
||||
; ..and its corresponding password (leave blank if using jellyfin_login)
|
||||
password = your password
|
||||
|
||||
debug = false
|
||||
|
||||
; Displayed at the bottom of all pages except admin
|
||||
contact_message = Need help? contact me.
|
||||
; Displayed at top of form page.
|
||||
help_message = Enter your details to create an account.
|
||||
; Displayed when an account is created.
|
||||
success_message = Your account has been created. Click below to continue to Jellyfin.
|
||||
|
||||
[password_validation]
|
||||
; Enables password validation.
|
||||
enabled = true
|
||||
; Min. password length
|
||||
min_length = 8
|
||||
; Min. number of uppercase characters
|
||||
upper = 1
|
||||
; Min. number of lowercase characters
|
||||
lower = 0
|
||||
; Min. number of numbers
|
||||
number = 1
|
||||
; Min. number of special characters
|
||||
special = 0
|
||||
|
||||
[email]
|
||||
; Leave this whole section if you aren't using any email-related features.
|
||||
use_24h = true
|
||||
; Date format follows datetime's strftime.
|
||||
date_format = %d/%m/%y
|
||||
; Displayed at bottom of emails
|
||||
message = Need help? contact me.
|
||||
; Mail methods: mailgun, smtp
|
||||
method = smtp
|
||||
; Address to send from
|
||||
address = jellyfin@jellyf.in
|
||||
; The name of the sender
|
||||
from = Jellyfin
|
||||
|
||||
[password_resets]
|
||||
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
|
||||
enabled = true
|
||||
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
|
||||
watch_directory = /path/to/jellyfin
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_html =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_text =
|
||||
; Subject of emails
|
||||
subject = Password Reset - Jellyfin
|
||||
|
||||
[invite_emails]
|
||||
; If enabled, allows one to send an invite directly to an email address.
|
||||
enabled = true
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_html =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_text =
|
||||
subject = Invite - Jellyfin
|
||||
; Base url for jf-accounts. This necessary because most will use a reverse proxy, so the program has no other way of knowing what URL to send.
|
||||
url_base = http://accounts.jellyf.in:8056/invite
|
||||
|
||||
[mailgun]
|
||||
|
||||
api_url = https://api.mailgun.net...
|
||||
api_key = your api key
|
||||
|
||||
[smtp]
|
||||
; Choose between ssl_tls and starttls. Your provider should tell you which to use, but generally SSL/TLS is 465, STARTTLS 587
|
||||
encryption = starttls
|
||||
server = smtp.jellyf.in
|
||||
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
|
||||
port = 465
|
||||
password = smtp password
|
||||
|
||||
[files]
|
||||
; When the below paths are left blank, files are stored in ~/.jf-accounts/.
|
||||
|
||||
; Path to store valid invites.
|
||||
invites =
|
||||
; Path to store emails addresses in JSON
|
||||
emails =
|
||||
; Path to the user policy template. Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_template =
|
||||
; Path to the user configuration template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_configuration =
|
||||
; Path to the user display preferences template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
|
||||
user_displayprefs =
|
||||
; Path to custom bootstrap.css
|
||||
custom_css =
|
||||
|
||||
7
jellyfin_accounts/data/created.txt
Normal file
7
jellyfin_accounts/data/created.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
A user was created using code {{ code }}.
|
||||
|
||||
Name: {{ username }}
|
||||
Address: {{ address }}
|
||||
Time: {{ time }}
|
||||
|
||||
Note: Notification emails can be toggled on the admin dashboard.
|
||||
@@ -1,242 +0,0 @@
|
||||
<!-- FILE: email.mjml -->
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-- -->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div style="">
|
||||
<!--[if mso | IE]>
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Noto Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1;text-align:left;color:#000000;">
|
||||
<p>Hi {{ username }},</p>
|
||||
<p> Someone has recently requested a password reset on Jellyfin.</p>
|
||||
<p>If this was you, enter the below pin into the prompt.</p>
|
||||
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
|
||||
<p>If this wasn't you, please ignore this email.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
|
||||
<p style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
|
||||
{{ pin }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,34 +0,0 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section background-color="#f0f0f0">
|
||||
<mj-column>
|
||||
<mj-text font-style="bold" font-size="20px">
|
||||
Jellyfin
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="15px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>Hi {{ username }},</p>
|
||||
<p> Someone has recently requested a password reset on Jellyfin.</p>
|
||||
<p>If this was you, enter the below pin into the prompt.</p>
|
||||
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
|
||||
<p>If this wasn't you, please ignore this email.</p>
|
||||
</mj-text>
|
||||
<mj-button>{{ pin }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#f0f0f0">
|
||||
<mj-column>
|
||||
<mj-text font-style="italic" font-size="10px">
|
||||
{{ message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
|
||||
5
jellyfin_accounts/data/expired.txt
Normal file
5
jellyfin_accounts/data/expired.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Invite expired.
|
||||
|
||||
Code {{ code }} expired at {{ expiry }}.
|
||||
|
||||
Note: Notification emails can be toggled on the admin dashboard.
|
||||
@@ -1,239 +0,0 @@
|
||||
<!-- FILE: invite-email.mjml -->
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-- -->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div style="">
|
||||
<!--[if mso | IE]>
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Noto Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1;text-align:left;color:#000000;">
|
||||
<p>Hi,</p>
|
||||
<h2>You've been invited to Jellyfin.</h2>
|
||||
<p>To join, click the button below.</p>
|
||||
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
|
||||
<a href="{{ invite_link }}" style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Setup your account </a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,33 +0,0 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section background-color="#f0f0f0">
|
||||
<mj-column>
|
||||
<mj-text font-style="bold" font-size="20px">
|
||||
Jellyfin
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="15px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>Hi,</p>
|
||||
<h2>You've been invited to Jellyfin.</h2>
|
||||
<p>To join, click the button below.</p>
|
||||
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
|
||||
</mj-text>
|
||||
<mj-button href="{{ invite_link }}">Setup your account</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#f0f0f0">
|
||||
<mj-column>
|
||||
<mj-text font-style="italic" font-size="10px">
|
||||
{{ message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
|
||||
8
jellyfin_accounts/data/services/jf-accounts.service
Normal file
8
jellyfin_accounts/data/services/jf-accounts.service
Normal file
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=A basic account management system for Jellyfin.
|
||||
|
||||
[Service]
|
||||
ExecStart={executable}
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,451 +0,0 @@
|
||||
function parseInvite(invite, empty = false) {
|
||||
if (empty === true) {
|
||||
return ["None", "", "1"]
|
||||
} else {
|
||||
var i = ["", "", "0", invite['email']];
|
||||
i[0] = invite['code'];
|
||||
if (invite['hours'] == 0) {
|
||||
i[1] = invite['minutes'] + 'm';
|
||||
} else if (invite['minutes'] == 0) {
|
||||
i[1] = invite['hours'] + 'h';
|
||||
} else {
|
||||
i[1] = invite['hours'] + 'h ' + invite['minutes'] + 'm';
|
||||
}
|
||||
i[1] = "Expires in " + i[1] + " ";
|
||||
return i
|
||||
}
|
||||
}
|
||||
function addItem(invite) {
|
||||
var links = document.getElementById('invites');
|
||||
var listItem = document.createElement('li');
|
||||
listItem.id = invite[0]
|
||||
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
|
||||
var listCode = document.createElement('div');
|
||||
listCode.classList.add('d-flex', 'align-items-center', 'text-monospace');
|
||||
var codeLink = document.createElement('a');
|
||||
codeLink.setAttribute('style', 'margin-right: 2%;');
|
||||
codeLink.appendChild(document.createTextNode(invite[0].replace(/-/g, '‑')));
|
||||
listCode.appendChild(codeLink);
|
||||
listItem.appendChild(listCode);
|
||||
var listRight = document.createElement('div');
|
||||
listText = document.createElement('span');
|
||||
listText.id = invite[0] + '_expiry'
|
||||
listText.appendChild(document.createTextNode(invite[1]));
|
||||
listRight.appendChild(listText);
|
||||
if (invite[2] == 0) {
|
||||
var inviteCode = window.location.href + 'invite/' + invite[0];
|
||||
codeLink.href = inviteCode;
|
||||
// listCode.appendChild(document.createTextNode(" "));
|
||||
var codeCopy = document.createElement('i');
|
||||
codeCopy.onclick = function(){toClipboard(inviteCode)};
|
||||
codeCopy.classList.add('fa', 'fa-clipboard');
|
||||
listCode.appendChild(codeCopy);
|
||||
if (typeof(invite[3]) != 'undefined') {
|
||||
var sentTo = document.createElement('span');
|
||||
sentTo.setAttribute('style', 'color: grey; margin-left: 2%; font-style: italic; font-size: 75%;');
|
||||
if (invite[3].includes('Failed to send to')) {
|
||||
sentTo.appendChild(document.createTextNode(invite[3]));
|
||||
} else {
|
||||
sentTo.appendChild(document.createTextNode('Sent to ' + invite[3]));
|
||||
}
|
||||
listCode.appendChild(sentTo);
|
||||
};
|
||||
var listDelete = document.createElement('button');
|
||||
listDelete.onclick = function(){deleteInvite(invite[0])};
|
||||
listDelete.classList.add('btn', 'btn-outline-danger');
|
||||
listDelete.appendChild(document.createTextNode('Delete'));
|
||||
listRight.appendChild(listDelete);
|
||||
};
|
||||
listItem.appendChild(listRight);
|
||||
links.appendChild(listItem);
|
||||
};
|
||||
function updateInvite(invite) {
|
||||
var expiry = document.getElementById(invite[0] + '_expiry');
|
||||
expiry.textContent = invite[1];
|
||||
}
|
||||
function removeInvite(code) {
|
||||
var item = document.getElementById(code);
|
||||
item.parentNode.removeChild(item);
|
||||
}
|
||||
function generateInvites(empty = false) {
|
||||
// document.getElementById('invites').textContent = '';
|
||||
if (empty === false) {
|
||||
$.ajax('/getInvites', {
|
||||
type : 'GET',
|
||||
dataType : 'json',
|
||||
contentType: 'json',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
data: { get_param: 'value' },
|
||||
complete: function(response) {
|
||||
var data = JSON.parse(response['responseText']);
|
||||
if (data['invites'].length == 0) {
|
||||
document.getElementById('invites').textContent = '';
|
||||
addItem(parseInvite([], true));
|
||||
} else {
|
||||
data['invites'].forEach(function(invite) {
|
||||
var match = false;
|
||||
var items = document.getElementById('invites').children;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].id == invite['code']) {
|
||||
match = true;
|
||||
updateInvite(parseInvite(invite));
|
||||
};
|
||||
};
|
||||
if (match == false) {
|
||||
addItem(parseInvite(invite));
|
||||
};
|
||||
});
|
||||
var items = document.getElementById('invites').children;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var exists = false;
|
||||
data['invites'].forEach(function(invite) {
|
||||
if (items[i].id == invite['code']) {
|
||||
exists = true;
|
||||
}
|
||||
});
|
||||
if (exists == false) {
|
||||
removeInvite(items[i].id);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (empty === true) {
|
||||
document.getElementById('invites').textContent = '';
|
||||
addItem(parseInvite([], true));
|
||||
};
|
||||
};
|
||||
function deleteInvite(code) {
|
||||
var send = JSON.stringify({ "code": code });
|
||||
$.ajax('/deleteInvite', {
|
||||
data : send,
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() { generateInvites(); },
|
||||
});
|
||||
};
|
||||
function addOptions(le, sel) {
|
||||
for (v = 0; v <= le; v++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.appendChild(document.createTextNode(v))
|
||||
opt.value = v
|
||||
sel.appendChild(opt)
|
||||
}
|
||||
};
|
||||
function toClipboard(str) {
|
||||
const el = document.createElement('textarea');
|
||||
el.value = str;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
const selected =
|
||||
document.getSelection().rangeCount > 0
|
||||
? document.getSelection().getRangeAt(0)
|
||||
: false;
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
if (selected) {
|
||||
document.getSelection().removeAllRanges();
|
||||
document.getSelection().addRange(selected);
|
||||
}
|
||||
};
|
||||
|
||||
$("form#inviteForm").submit(function() {
|
||||
var button = document.getElementById('generateSubmit');
|
||||
button.disabled = true;
|
||||
button.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
var send_object = $("form#inviteForm").serializeObject();
|
||||
if (document.getElementById('send_to_address') != null) {
|
||||
if (document.getElementById('send_to_address_enabled').checked) {
|
||||
send_object['email'] = document.getElementById('send_to_address').value;
|
||||
}
|
||||
}
|
||||
var send = JSON.stringify(send_object);
|
||||
$.ajax('/generateInvite', {
|
||||
data : send,
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() {
|
||||
button.textContent = 'Generate';
|
||||
button.disabled = false;
|
||||
generateInvites();
|
||||
},
|
||||
|
||||
});
|
||||
return false;
|
||||
});
|
||||
$("form#loginForm").submit(function() {
|
||||
window.token = "";
|
||||
var details = $("form#loginForm").serializeObject();
|
||||
var errorArea = document.getElementById('loginErrorArea');
|
||||
errorArea.textContent = '';
|
||||
var button = document.getElementById('loginSubmit');
|
||||
button.disabled = true;
|
||||
button.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
$.ajax('/getToken', {
|
||||
type : 'GET',
|
||||
dataType : 'json',
|
||||
contentType: 'json',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password']));
|
||||
},
|
||||
data: { get_param: 'value' },
|
||||
complete: function(data) {
|
||||
if (data['status'] == 401) {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Login';
|
||||
var wrongPassword = document.createElement('div');
|
||||
wrongPassword.classList.add('alert', 'alert-danger');
|
||||
wrongPassword.setAttribute('role', 'alert');
|
||||
wrongPassword.appendChild(document.createTextNode('Incorrect username or password.'));
|
||||
errorArea.appendChild(wrongPassword);
|
||||
} else {
|
||||
window.token = JSON.parse(data['responseText'])['token'];
|
||||
generateInvites();
|
||||
var interval = setInterval(function() { generateInvites(); }, 60 * 1000);
|
||||
var hour = document.getElementById('hours');
|
||||
addOptions(24, hour);
|
||||
hour.selected = "0";
|
||||
var minutes = document.getElementById('minutes');
|
||||
addOptions(59, minutes);
|
||||
minutes.selected = "30";
|
||||
$('#login').modal('hide');
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
document.getElementById('openSettings').onclick = function () {
|
||||
$('#settingsMenu').modal('show');
|
||||
}
|
||||
document.getElementById('openDefaultsWizard').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
$.ajax('getUsers', {
|
||||
type : 'GET',
|
||||
dataType : 'json',
|
||||
contentType : 'json',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
complete : function(data) {
|
||||
if (data['status'] == 200) {
|
||||
var radioList = document.getElementById('defaultUserRadios');
|
||||
radioList.textContent = '';
|
||||
if (document.getElementById('setDefaultUser')) {
|
||||
document.getElementById('setDefaultUser').remove();
|
||||
};
|
||||
var users = data['responseJSON']['users'];
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
var user = users[i]
|
||||
var radio = document.createElement('div');
|
||||
radio.classList.add('radio');
|
||||
if (i == 0) {
|
||||
var checked = 'checked';
|
||||
} else {
|
||||
var checked = '';
|
||||
};
|
||||
radio.innerHTML =
|
||||
'<label><input type="radio" name="defaultRadios" id="default_' +
|
||||
user['name'] + '" style="margin-right: 1rem;"' + checked + '>' +
|
||||
user['name'] + '</label>';
|
||||
radioList.appendChild(radio);
|
||||
}
|
||||
var button = document.getElementById('openDefaultsWizard');
|
||||
button.disabled = false;
|
||||
button.innerHTML = 'Set new account defaults';
|
||||
var submitButton = document.getElementById('storeDefaults');
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Submit';
|
||||
if (submitButton.classList.contains('btn-success')) {
|
||||
submitButton.classList.remove('btn-success');
|
||||
submitButton.classList.add('btn-primary');
|
||||
} else if (submitButton.classList.contains('btn-danger')) {
|
||||
submitButton.classList.remove('btn-danger');
|
||||
submitButton.classList.add('btn-primary');
|
||||
}
|
||||
$('#userDefaults').modal('show');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
document.getElementById('storeDefaults').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
var button = document.getElementById('storeDefaults');
|
||||
var radios = document.getElementsByName('defaultRadios');
|
||||
for (var i = 0; i < radios.length; i++) {
|
||||
if (radios[i].checked) {
|
||||
var data = {'username':radios[i].id.slice(8), 'homescreen':false};
|
||||
if (document.getElementById('storeDefaultHomescreen').checked) {
|
||||
data['homescreen'] = true;
|
||||
}
|
||||
$.ajax('/setDefaults', {
|
||||
data : JSON.stringify(data),
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() {
|
||||
button.textContent = 'Success';
|
||||
if (button.classList.contains('btn-danger')) {
|
||||
button.classList.remove('btn-danger');
|
||||
} else if (button.classList.contains('btn-primary')) {
|
||||
button.classList.remove('btn-primary');
|
||||
};
|
||||
button.classList.add('btn-success');
|
||||
button.disabled = false;
|
||||
setTimeout(function(){$('#userDefaults').modal('hide');}, 1000);
|
||||
},
|
||||
error: function() {
|
||||
button.textContent = 'Failed';
|
||||
button.classList.remove('btn-primary');
|
||||
button.classList.add('btn-danger');
|
||||
setTimeout(function(){
|
||||
var button = document.getElementById('storeDefaults');
|
||||
button.textContent = 'Submit';
|
||||
button.classList.remove('btn-danger');
|
||||
button.classList.add('btn-primary');
|
||||
button.disabled = false;
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
document.getElementById('openUsers').onclick = function () {
|
||||
this.disabled = true;
|
||||
this.innerHTML =
|
||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||
'Loading...';
|
||||
$.ajax('/getUsers', {
|
||||
type : 'GET',
|
||||
dataType : 'json',
|
||||
contentType: 'json',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
data: { get_param: 'value' },
|
||||
complete : function(data) {
|
||||
if (data['status'] == 200) {
|
||||
var list = document.getElementById('userList');
|
||||
list.textContent = '';
|
||||
if (document.getElementById('saveUsers')) {
|
||||
document.getElementById('saveUsers').remove();
|
||||
};
|
||||
var users = data['responseJSON']['users'];
|
||||
for (var i = 0; i < users.length; i++) {
|
||||
var user = users[i]
|
||||
var entry = document.createElement('p');
|
||||
entry.id = 'user_' + user['name'];
|
||||
entry.appendChild(document.createTextNode(user['name']));
|
||||
var address = document.createElement('span');
|
||||
address.setAttribute('style', 'margin-left: 2%; margin-right: 2%; color: grey;');
|
||||
address.classList.add('addressText');
|
||||
address.id = 'address_' + user['email'];
|
||||
if (typeof(user['email']) != 'undefined') {
|
||||
address.appendChild(document.createTextNode(user['email']));
|
||||
};
|
||||
var editButton = document.createElement('i');
|
||||
editButton.classList.add('fa', 'fa-edit');
|
||||
editButton.onclick = function() {
|
||||
this.classList.remove('fa', 'fa-edit');
|
||||
var input = document.createElement('input');
|
||||
input.setAttribute('type', 'email');
|
||||
input.setAttribute('style', 'margin-left: 2%; color: grey;');
|
||||
var addressElement = this.parentNode.getElementsByClassName('addressText')[0];
|
||||
if (addressElement.textContent != '') {
|
||||
input.value = addressElement.textContent;
|
||||
} else {
|
||||
input.placeholder = 'Email Address';
|
||||
};
|
||||
this.parentNode.replaceChild(input, addressElement);
|
||||
if (document.getElementById('saveUsers') == null) {
|
||||
var footer = document.getElementById('userFooter')
|
||||
var saveUsers = document.createElement('input');
|
||||
saveUsers.classList.add('btn', 'btn-primary');
|
||||
saveUsers.setAttribute('type', 'button');
|
||||
saveUsers.value = 'Save Changes';
|
||||
saveUsers.id = 'saveUsers';
|
||||
saveUsers.onclick = function() {
|
||||
var send = {}
|
||||
var entries = document.getElementById('userList').children;
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var entry = entries[i];
|
||||
if (typeof(entry.getElementsByTagName('input')[0]) != 'undefined') {
|
||||
var name = entry.id.replace(/user_/g, '')
|
||||
var address = entry.getElementsByTagName('input')[0].value;
|
||||
send[name] = address
|
||||
};
|
||||
};
|
||||
send = JSON.stringify(send);
|
||||
$.ajax('/modifyUsers', {
|
||||
data : send,
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
xhrFields : {
|
||||
withCredentials: true
|
||||
},
|
||||
beforeSend : function (xhr) {
|
||||
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||
},
|
||||
success: function() { $('#users').modal('hide'); },
|
||||
});
|
||||
};
|
||||
footer.appendChild(saveUsers);
|
||||
};
|
||||
};
|
||||
entry.appendChild(address);
|
||||
entry.appendChild(editButton);
|
||||
list.appendChild(entry);
|
||||
};
|
||||
var button = document.getElementById('openUsers');
|
||||
button.disabled = false;
|
||||
button.innerHTML = 'Users <i class="fa fa-user"></i>';
|
||||
$('#users').modal('show');
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
generateInvites(empty = true);
|
||||
$("#login").modal('show');
|
||||
File diff suppressed because one or more lines are too long
26
jellyfin_accounts/data/static/serialize.js
Normal file
26
jellyfin_accounts/data/static/serialize.js
Normal 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;
|
||||
};
|
||||
@@ -13,16 +13,20 @@ for (var i = 0; i < authRadios.length; i++) {
|
||||
checkAuthRadio();
|
||||
});
|
||||
};
|
||||
|
||||
function checkEmailRadio() {
|
||||
document.getElementById('emailNextButton').href = '#page-5';
|
||||
document.getElementById('valBackButton').href = '#page-7';
|
||||
if (document.getElementById('emailSMTPRadio').checked) {
|
||||
document.getElementById('emailCommonArea').style.display = '';
|
||||
document.getElementById('emailSMTPArea').style.display = '';
|
||||
document.getElementById('emailMailgunArea').style.display = 'none';
|
||||
} else if (document.getElementById('emailMailgunRadio').checked) {
|
||||
document.getElementById('emailCommonArea').style.display = '';
|
||||
document.getElementById('emailSMTPArea').style.display = 'none';
|
||||
document.getElementById('emailMailgunArea').style.display = '';
|
||||
} else if (document.getElementById('emailDisabledRadio').checked) {
|
||||
document.getElementById('emailCommonArea').style.display = 'none';
|
||||
document.getElementById('emailSMTPArea').style.display = 'none';
|
||||
document.getElementById('emailMailgunArea').style.display = 'none';
|
||||
document.getElementById('emailNextButton').href = '#page-8';
|
||||
@@ -35,6 +39,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 +106,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 +122,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() {
|
||||
@@ -138,6 +143,7 @@ document.getElementById('submitButton').onclick = function() {
|
||||
config['invite_emails'] = {};
|
||||
config['mailgun'] = {};
|
||||
config['smtp'] = {};
|
||||
config['notifications'] = {};
|
||||
// Page 2: Auth
|
||||
if (document.getElementById('jfAuthRadio').checked) {
|
||||
config['ui']['jellyfin_login'] = 'true';
|
||||
@@ -149,6 +155,7 @@ document.getElementById('submitButton').onclick = function() {
|
||||
} else {
|
||||
config['ui']['username'] = document.getElementById('manualAuthUsername').value;
|
||||
config['ui']['password'] = document.getElementById('manualAuthPassword').value;
|
||||
config['ui']['email'] = document.getElementById('manualAuthEmail').value;
|
||||
};
|
||||
// Page 3: Connect to jellyfin
|
||||
config['jellyfin']['server'] = document.getElementById('jfHost').value;
|
||||
@@ -158,7 +165,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';
|
||||
@@ -176,6 +183,7 @@ document.getElementById('submitButton').onclick = function() {
|
||||
config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value;
|
||||
config['email']['address'] = document.getElementById('emailMailgunAddress').value;
|
||||
};
|
||||
config['notifications']['enabled'] = document.getElementById('notificationsEnabled').checked.toString();
|
||||
// Page 5: Email formatting
|
||||
config['email']['from'] = document.getElementById('emailSender').value;
|
||||
config['email']['date_format'] = document.getElementById('emailDateFormat').value;
|
||||
@@ -211,24 +219,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));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#603cba">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<title>404</title>
|
||||
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
|
||||
<style>
|
||||
.messageBox {
|
||||
margin: 20%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messageBox">
|
||||
<h1>Page not found.</h1>
|
||||
<p>
|
||||
{{ contactMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
<title>404</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ css_file }}">
|
||||
{% if not bs5 %}
|
||||
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
|
||||
{% if bs5 %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
|
||||
{% else %}
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<style>
|
||||
.messageBox {
|
||||
margin: 20%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messageBox">
|
||||
<h1>Page not found.</h1>
|
||||
<p>
|
||||
{{ contactMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,18 +12,53 @@
|
||||
<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 %}
|
||||
var css = document.createElement('link');
|
||||
css.setAttribute('rel', 'stylesheet');
|
||||
css.setAttribute('type', 'text/css');
|
||||
var cssCookie = getCookie("css");
|
||||
if (cssCookie.includes('bs' + bsVersion)) {
|
||||
css.setAttribute('href', cssCookie);
|
||||
} else {
|
||||
css.setAttribute('href', '{{ css_file }}');
|
||||
};
|
||||
document.head.appendChild(css);
|
||||
</script>
|
||||
{% if not bs5 %}
|
||||
<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 +84,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 +143,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 +154,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 +191,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 +214,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 +316,7 @@
|
||||
<p>{{ contactMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="serialize.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1022
jellyfin_accounts/data/templates/admin.js
Normal file
1022
jellyfin_accounts/data/templates/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -89,6 +89,11 @@
|
||||
<label for="manualAuthPassword">Password</label>
|
||||
<input type="password" class="form-control" id="manualAuthPassword" placeholder="Password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="manualAuthEmail">Email (Optional)</label>
|
||||
<input type="email" class="form-control" id="manualAuthEmail" placeholder="example@example.com">
|
||||
<small class="form-text text-muted">Your email address is only required if you want to recieve activity notifications.</small>
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
|
||||
@@ -180,6 +185,15 @@
|
||||
<input type="email" class="form-control" id="emailMailgunAddress" placeholder="jellyfin@jellyf.in">
|
||||
</div>
|
||||
</div>
|
||||
<div id="emailCommonArea">
|
||||
<h5 class="card-title">Notifications</h5>
|
||||
<p class="card-text">Enabling notifications will allow you to choose (per-invite) to recieve emails when an invite expires, or when a new user is created. If you chose to use Manual auth instead of Jellyfin auth previously, make sure you provided an email address.</p>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="notificationsEnabled">
|
||||
<label for="notificationsEnabled" class="form-check-label">Enabled</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
<div class="btn-group float-right" role="group" aria-label="Back/Next buttons">
|
||||
<a class="btn btn-secondary backButton" href="#page-3">Back</a>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
# Automatic storage of everything except the config
|
||||
import json
|
||||
import datetime
|
||||
|
||||
|
||||
class JSONFile(dict):
|
||||
"""
|
||||
Behaves like a dictionary, but automatically
|
||||
reads and writes to a JSON file (most of the time).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def readJSON(path):
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def writeJSON(path, data):
|
||||
with open(path, 'w') as f:
|
||||
with open(path, "w") as f:
|
||||
return f.write(json.dumps(data, indent=4, default=str))
|
||||
|
||||
|
||||
def __init__(self, path, data=None):
|
||||
self.path = path
|
||||
if data is None:
|
||||
@@ -30,14 +37,17 @@ class JSONFile(dict):
|
||||
def __setitem__(self, key, value):
|
||||
data = self.readJSON(self.path)
|
||||
data[key] = value
|
||||
self.writeJSON(self.path, data)
|
||||
self.writeJSON(self.path, data)
|
||||
super(JSONFile, self).__init__(data)
|
||||
|
||||
def __delitem__(self, key):
|
||||
data = self.readJSON(self.path)
|
||||
super(JSONFile, self).__init__(data)
|
||||
del data[key]
|
||||
self.writeJSON(self.path, data)
|
||||
try:
|
||||
del data[key]
|
||||
except KeyError:
|
||||
pass
|
||||
self.writeJSON(self.path, data)
|
||||
super(JSONFile, self).__delitem__(key)
|
||||
|
||||
def __str__(self):
|
||||
@@ -46,18 +56,15 @@ class JSONFile(dict):
|
||||
|
||||
|
||||
class JSONStorage:
|
||||
def __init__(self,
|
||||
emails,
|
||||
invites,
|
||||
user_template,
|
||||
user_displayprefs,
|
||||
user_configuration):
|
||||
def __init__(
|
||||
self, emails, invites, user_template, user_displayprefs, user_configuration
|
||||
):
|
||||
self.emails = JSONFile(path=emails)
|
||||
self.invites = JSONFile(path=invites)
|
||||
self.user_template = JSONFile(path=user_template)
|
||||
self.user_displayprefs = JSONFile(path=user_displayprefs)
|
||||
self.user_configuration = JSONFile(path=user_configuration)
|
||||
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if hasattr(self, name):
|
||||
path = self.__dict__[name].path
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Handles everything related to emails
|
||||
import datetime
|
||||
import pytz
|
||||
import requests
|
||||
@@ -12,97 +13,157 @@ from jellyfin_accounts import config
|
||||
from jellyfin_accounts import email_log as log
|
||||
|
||||
|
||||
class Email():
|
||||
def format_datetime(dt):
|
||||
result = dt.strftime(config["email"]["date_format"])
|
||||
if config.getboolean("email", "use_24h"):
|
||||
result += f' {dt.strftime("%H:%M")}'
|
||||
else:
|
||||
result += f' {dt.strftime("%I:%M %p")}'
|
||||
return result
|
||||
|
||||
|
||||
class Email:
|
||||
def __init__(self, address):
|
||||
self.address = address
|
||||
log.debug(f'{self.address}: Creating email')
|
||||
log.debug(f"{self.address}: Creating email")
|
||||
self.content = {}
|
||||
self.from_address = config['email']['address']
|
||||
self.from_name = config['email']['from']
|
||||
log.debug((
|
||||
f'{self.address}: Sending from {self.from_address} ' +
|
||||
f'({self.from_name})'))
|
||||
self.from_address = config["email"]["address"]
|
||||
self.from_name = config["email"]["from"]
|
||||
log.debug(
|
||||
(
|
||||
f"{self.address}: Sending from {self.from_address} "
|
||||
+ f"({self.from_name})"
|
||||
)
|
||||
)
|
||||
|
||||
def pretty_time(self, expiry):
|
||||
current_time = datetime.datetime.now()
|
||||
date = expiry.strftime(config['email']['date_format'])
|
||||
if config.getboolean('email', 'use_24h'):
|
||||
log.debug(f'{self.address}: Using 24h time')
|
||||
time = expiry.strftime('%H:%M')
|
||||
date = expiry.strftime(config["email"]["date_format"])
|
||||
if config.getboolean("email", "use_24h"):
|
||||
log.debug(f"{self.address}: Using 24h time")
|
||||
time = expiry.strftime("%H:%M")
|
||||
else:
|
||||
log.debug(f'{self.address}: Using 12h time')
|
||||
time = expiry.strftime('%-I:%M %p')
|
||||
log.debug(f"{self.address}: Using 12h time")
|
||||
time = expiry.strftime("%-I:%M %p")
|
||||
expiry_delta = (expiry - current_time).seconds
|
||||
expires_in = {'hours': expiry_delta//3600,
|
||||
'minutes': (expiry_delta//60) % 60}
|
||||
if expires_in['hours'] == 0:
|
||||
expires_in = {
|
||||
"hours": expiry_delta // 3600,
|
||||
"minutes": (expiry_delta // 60) % 60,
|
||||
}
|
||||
if expires_in["hours"] == 0:
|
||||
expires_in = f'{str(expires_in["minutes"])}m'
|
||||
else:
|
||||
expires_in = (f'{str(expires_in["hours"])}h ' +
|
||||
f'{str(expires_in["minutes"])}m')
|
||||
log.debug(f'{self.address}: Expires in {expires_in}')
|
||||
return {'date': date, 'time': time, 'expires_in': expires_in}
|
||||
expires_in = f'{str(expires_in["hours"])}h {str(expires_in["minutes"])}m'
|
||||
log.debug(f"{self.address}: Expires in {expires_in}")
|
||||
return {"date": date, "time": time, "expires_in": expires_in}
|
||||
|
||||
def construct_invite(self, invite):
|
||||
self.subject = config['invite_emails']['subject']
|
||||
log.debug(f'{self.address}: Using subject {self.subject}')
|
||||
log.debug(f'{self.address}: Constructing email content')
|
||||
expiry = invite['expiry']
|
||||
self.subject = config["invite_emails"]["subject"]
|
||||
log.debug(f"{self.address}: Using subject {self.subject}")
|
||||
log.debug(f"{self.address}: Constructing email content")
|
||||
expiry = invite["expiry"]
|
||||
expiry.replace(tzinfo=None)
|
||||
pretty = self.pretty_time(expiry)
|
||||
email_message = config['email']['message']
|
||||
invite_link = config['invite_emails']['url_base']
|
||||
invite_link += '/' + invite['code']
|
||||
for key in ['text', 'html']:
|
||||
sp = Path(config['invite_emails']['email_' + key]) / '..'
|
||||
sp = str(sp.resolve()) + '/'
|
||||
email_message = config["email"]["message"]
|
||||
invite_link = config["invite_emails"]["url_base"]
|
||||
invite_link += "/" + invite["code"]
|
||||
for key in ["text", "html"]:
|
||||
sp = Path(config["invite_emails"]["email_" + key]) / ".."
|
||||
sp = str(sp.resolve()) + "/"
|
||||
template_loader = FileSystemLoader(searchpath=sp)
|
||||
template_env = Environment(loader=template_loader)
|
||||
fname = Path(config['invite_emails']['email_' + key]).name
|
||||
fname = Path(config["invite_emails"]["email_" + key]).name
|
||||
template = template_env.get_template(fname)
|
||||
c = template.render(expiry_date=pretty['date'],
|
||||
expiry_time=pretty['time'],
|
||||
expires_in=pretty['expires_in'],
|
||||
invite_link=invite_link,
|
||||
message=email_message)
|
||||
c = template.render(
|
||||
expiry_date=pretty["date"],
|
||||
expiry_time=pretty["time"],
|
||||
expires_in=pretty["expires_in"],
|
||||
invite_link=invite_link,
|
||||
message=email_message,
|
||||
)
|
||||
self.content[key] = c
|
||||
log.info(f'{self.address}: {key} constructed')
|
||||
log.info(f"{self.address}: {key} constructed")
|
||||
|
||||
def construct_expiry(self, invite):
|
||||
self.subject = "Notice: Invite expired"
|
||||
log.debug(f'Constructing expiry notification for {invite["code"]}')
|
||||
expiry = format_datetime(invite["expiry"])
|
||||
for key in ["text", "html"]:
|
||||
sp = Path(config["notifications"]["expiry_" + key]) / ".."
|
||||
sp = str(sp.resolve()) + "/"
|
||||
template_loader = FileSystemLoader(searchpath=sp)
|
||||
template_env = Environment(loader=template_loader)
|
||||
fname = Path(config["notifications"]["expiry_" + key]).name
|
||||
template = template_env.get_template(fname)
|
||||
c = template.render(code=invite["code"], expiry=expiry)
|
||||
self.content[key] = c
|
||||
log.info(f"{self.address}: {key} constructed")
|
||||
return True
|
||||
|
||||
def construct_created(self, invite):
|
||||
self.subject = "Notice: User created"
|
||||
log.debug(f'Constructing expiry notification for {invite["code"]}')
|
||||
created = format_datetime(invite["created"])
|
||||
if config.getboolean("email", "no_username"):
|
||||
email = "n/a"
|
||||
else:
|
||||
email = invite["address"]
|
||||
for key in ["text", "html"]:
|
||||
sp = Path(config["notifications"]["created_" + key]) / ".."
|
||||
sp = str(sp.resolve()) + "/"
|
||||
template_loader = FileSystemLoader(searchpath=sp)
|
||||
template_env = Environment(loader=template_loader)
|
||||
fname = Path(config["notifications"]["created_" + key]).name
|
||||
template = template_env.get_template(fname)
|
||||
c = template.render(
|
||||
code=invite["code"],
|
||||
username=invite["username"],
|
||||
address=email,
|
||||
time=created,
|
||||
)
|
||||
self.content[key] = c
|
||||
log.info(f"{self.address}: {key} constructed")
|
||||
return True
|
||||
|
||||
def construct_reset(self, reset):
|
||||
self.subject = config['password_resets']['subject']
|
||||
log.debug(f'{self.address}: Using subject {self.subject}')
|
||||
log.debug(f'{self.address}: Constructing email content')
|
||||
self.subject = config["password_resets"]["subject"]
|
||||
log.debug(f"{self.address}: Using subject {self.subject}")
|
||||
log.debug(f"{self.address}: Constructing email content")
|
||||
try:
|
||||
expiry = date_parser.parse(reset['ExpirationDate'])
|
||||
expiry = date_parser.parse(reset["ExpirationDate"])
|
||||
expiry = expiry.replace(tzinfo=None)
|
||||
except:
|
||||
log.error(f"{self.address}: Couldn't parse expiry time")
|
||||
return False
|
||||
current_time = datetime.datetime.now()
|
||||
if expiry >= current_time:
|
||||
log.debug(f'{self.address}: Invite valid')
|
||||
log.debug(f"{self.address}: Invite valid")
|
||||
pretty = self.pretty_time(expiry)
|
||||
email_message = config['email']['message']
|
||||
for key in ['text', 'html']:
|
||||
sp = Path(config['password_resets']['email_' + key]) / '..'
|
||||
sp = str(sp.resolve()) + '/'
|
||||
email_message = config["email"]["message"]
|
||||
for key in ["text", "html"]:
|
||||
sp = Path(config["password_resets"]["email_" + key]) / ".."
|
||||
sp = str(sp.resolve()) + "/"
|
||||
template_loader = FileSystemLoader(searchpath=sp)
|
||||
template_env = Environment(loader=template_loader)
|
||||
fname = Path(config['password_resets']['email_' + key]).name
|
||||
fname = Path(config["password_resets"]["email_" + key]).name
|
||||
template = template_env.get_template(fname)
|
||||
c = template.render(username=reset['UserName'],
|
||||
expiry_date=pretty['date'],
|
||||
expiry_time=pretty['time'],
|
||||
expires_in=pretty['expires_in'],
|
||||
pin=reset['Pin'],
|
||||
message=email_message)
|
||||
c = template.render(
|
||||
username=reset["UserName"],
|
||||
expiry_date=pretty["date"],
|
||||
expiry_time=pretty["time"],
|
||||
expires_in=pretty["expires_in"],
|
||||
pin=reset["Pin"],
|
||||
message=email_message,
|
||||
)
|
||||
self.content[key] = c
|
||||
log.info(f'{self.address}: {key} constructed')
|
||||
log.info(f"{self.address}: {key} constructed")
|
||||
return True
|
||||
else:
|
||||
err = ((f"{self.address}: " +
|
||||
"Reset has reportedly already expired. " +
|
||||
"Ensure timezones are correctly configured."))
|
||||
err = (
|
||||
f"{self.address}: "
|
||||
+ "Reset has reportedly already expired. "
|
||||
+ "Ensure timezones are correctly configured."
|
||||
)
|
||||
log.error(err)
|
||||
return False
|
||||
|
||||
@@ -110,71 +171,74 @@ class Email():
|
||||
class Mailgun(Email):
|
||||
def __init__(self, address):
|
||||
super().__init__(address)
|
||||
self.api_url = config['mailgun']['api_url']
|
||||
self.api_key = config['mailgun']['api_key']
|
||||
self.from_mg = f'{self.from_name} <{self.from_address}>'
|
||||
self.api_url = config["mailgun"]["api_url"]
|
||||
self.api_key = config["mailgun"]["api_key"]
|
||||
self.from_mg = f"{self.from_name} <{self.from_address}>"
|
||||
|
||||
def send(self):
|
||||
response = requests.post(self.api_url,
|
||||
auth=("api", self.api_key),
|
||||
data={"from": self.from_mg,
|
||||
"to": [self.address],
|
||||
"subject": self.subject,
|
||||
"text": self.content['text'],
|
||||
"html": self.content['html']})
|
||||
response = requests.post(
|
||||
self.api_url,
|
||||
auth=("api", self.api_key),
|
||||
data={
|
||||
"from": self.from_mg,
|
||||
"to": [self.address],
|
||||
"subject": self.subject,
|
||||
"text": self.content["text"],
|
||||
"html": self.content["html"],
|
||||
},
|
||||
)
|
||||
if response.ok:
|
||||
log.info(f'{self.address}: Sent via mailgun.')
|
||||
log.info(f"{self.address}: Sent via mailgun.")
|
||||
return True
|
||||
log.debug(f'{self.address}: Mailgun: {response.status_code}')
|
||||
log.debug(f"{self.address}: Mailgun: {response.status_code}")
|
||||
return response
|
||||
|
||||
|
||||
class Smtp(Email):
|
||||
def __init__(self, address):
|
||||
super().__init__(address)
|
||||
self.server = config['smtp']['server']
|
||||
self.password = config['smtp']['password']
|
||||
self.server = config["smtp"]["server"]
|
||||
self.password = config["smtp"]["password"]
|
||||
try:
|
||||
self.port = int(config['smtp']['port'])
|
||||
self.port = int(config["smtp"]["port"])
|
||||
except ValueError:
|
||||
self.port = 465
|
||||
log.debug(f'{self.address}: Defaulting to port {self.port}')
|
||||
log.debug(f"{self.address}: Defaulting to port {self.port}")
|
||||
|
||||
def send(self):
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = self.subject
|
||||
message["From"] = self.from_address
|
||||
message["To"] = self.address
|
||||
text = MIMEText(self.content['text'], 'plain')
|
||||
html = MIMEText(self.content['html'], 'html')
|
||||
text = MIMEText(self.content["text"], "plain")
|
||||
html = MIMEText(self.content["html"], "html")
|
||||
message.attach(text)
|
||||
message.attach(html)
|
||||
try:
|
||||
if config['smtp']['encryption'] == 'ssl_tls':
|
||||
if config["smtp"]["encryption"] == "ssl_tls":
|
||||
self.context = ssl.create_default_context()
|
||||
with smtplib.SMTP_SSL(self.server,
|
||||
self.port,
|
||||
context=self.context) as server:
|
||||
with smtplib.SMTP_SSL(
|
||||
self.server, self.port, context=self.context
|
||||
) as server:
|
||||
server.ehlo()
|
||||
server.login(self.from_address, self.password)
|
||||
server.sendmail(self.from_address,
|
||||
self.address,
|
||||
message.as_string())
|
||||
log.info(f'{self.address}: Sent via smtp (ssl/tls)')
|
||||
server.sendmail(
|
||||
self.from_address, self.address, message.as_string()
|
||||
)
|
||||
log.info(f"{self.address}: Sent via smtp (ssl/tls)")
|
||||
return True
|
||||
elif config['smtp']['encryption'] == 'starttls':
|
||||
with smtplib.SMTP(self.server,
|
||||
self.port) as server:
|
||||
elif config["smtp"]["encryption"] == "starttls":
|
||||
with smtplib.SMTP(self.server, self.port) as server:
|
||||
server.ehlo()
|
||||
server.starttls()
|
||||
server.login(self.from_address, self.password)
|
||||
server.sendmail(self.from_address,
|
||||
self.address,
|
||||
message.as_string())
|
||||
log.info(f'{self.address}: Sent via smtp (starttls)')
|
||||
server.sendmail(
|
||||
self.from_address, self.address, message.as_string()
|
||||
)
|
||||
log.info(f"{self.address}: Sent via smtp (starttls)")
|
||||
return True
|
||||
except Exception as e:
|
||||
err = f'{self.address}: Failed to send via smtp: '
|
||||
err = f"{self.address}: Failed to send via smtp: "
|
||||
err += type(e).__name__
|
||||
log.error(err)
|
||||
try:
|
||||
|
||||
36
jellyfin_accounts/generate_ini.py
Normal file
36
jellyfin_accounts/generate_ini.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generates config file
|
||||
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
|
||||
42
jellyfin_accounts/invite_daemon.py
Normal file
42
jellyfin_accounts/invite_daemon.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from threading import Timer
|
||||
import time
|
||||
from jellyfin_accounts import config, data_store
|
||||
from jellyfin_accounts.web_api import checkInvite
|
||||
|
||||
|
||||
class Repeat:
|
||||
def __init__(self, interval, function, *args, **kwargs):
|
||||
self._timer = None
|
||||
self.interval = interval
|
||||
self.function = function
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.is_running = False
|
||||
self.next_call = time.time()
|
||||
self.start()
|
||||
|
||||
def _run(self):
|
||||
self.is_running = False
|
||||
self.start()
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
def start(self):
|
||||
if not self.is_running:
|
||||
self.next_call += self.interval
|
||||
self._timer = Timer(self.next_call - time.time(), self._run)
|
||||
self._timer.start()
|
||||
self.is_running = True
|
||||
|
||||
def stop(self):
|
||||
self._timer.cancel()
|
||||
self.is_running = False
|
||||
|
||||
|
||||
def checkInvites():
|
||||
invites = dict(data_store.invites)
|
||||
# checkInvite already loops over everything, no point running it multiple times.
|
||||
checkInvite(list(invites.keys())[0])
|
||||
|
||||
|
||||
if config.getboolean("notifications", "enabled"):
|
||||
inviteDaemon = Repeat(60, checkInvites)
|
||||
@@ -1,36 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
# Jellyfin API client
|
||||
import requests
|
||||
import time
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Jellyfin:
|
||||
"""
|
||||
Basic Jellyfin API client, providing account related function only.
|
||||
"""
|
||||
|
||||
class UserExistsError(Error):
|
||||
"""
|
||||
Thrown if a user already exists with the same name
|
||||
when creating an account.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class UserNotFoundError(Error):
|
||||
"""Thrown if account with specified user ID/name does not exist."""
|
||||
|
||||
pass
|
||||
|
||||
class AuthenticationError(Error):
|
||||
"""Thrown if authentication with Jellyfin fails."""
|
||||
|
||||
pass
|
||||
|
||||
class AuthenticationRequiredError(Error):
|
||||
"""
|
||||
Thrown if privileged action is attempted without authentication.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
class UnknownError(Error):
|
||||
"""
|
||||
Thrown if i've been too lazy to figure out an error's meaning.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def __init__(self, server, client, version, device, deviceId):
|
||||
"""
|
||||
Initializes the Jellyfin object. All parameters except server
|
||||
@@ -58,40 +71,46 @@ class Jellyfin:
|
||||
self.auth += f"DeviceId={self.deviceId}, "
|
||||
self.auth += f"Version={self.version}"
|
||||
self.header = {
|
||||
"Accept": "application/json",
|
||||
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Application": f"{self.client}/{self.version}",
|
||||
"Accept-Charset": "UTF-8,*",
|
||||
"Accept-encoding": "gzip",
|
||||
"User-Agent": self.useragent,
|
||||
"X-Emby-Authorization": self.auth
|
||||
"Accept": "application/json",
|
||||
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-Application": f"{self.client}/{self.version}",
|
||||
"Accept-Charset": "UTF-8,*",
|
||||
"Accept-encoding": "gzip",
|
||||
"User-Agent": self.useragent,
|
||||
"X-Emby-Authorization": self.auth,
|
||||
}
|
||||
def getUsers(self, username="all", id="all", public=True):
|
||||
try:
|
||||
self.info = requests.get(self.server + "/System/Info/Public").json()
|
||||
except:
|
||||
pass
|
||||
|
||||
def getUsers(self, username: str = "all", userId: str = "all", public: bool = True):
|
||||
"""
|
||||
Returns details on user(s), such as ID, Name, Policy.
|
||||
|
||||
:param username: (optional) Username to get info about.
|
||||
Leave blank to get all users.
|
||||
:param id: (optional) User ID to get info about.
|
||||
:param userId: (optional) User ID to get info about.
|
||||
Leave blank to get all users.
|
||||
:param public: True = Get publicly visible users only (no auth required),
|
||||
False = Get all users (auth required).
|
||||
"""
|
||||
if public is True:
|
||||
if (time.time() - self.userCachePublicAge) >= self.timeout:
|
||||
response = requests.get(self.server+"/emby/Users/Public").json()
|
||||
response = requests.get(self.server + "/emby/Users/Public").json()
|
||||
self.userCachePublic = response
|
||||
self.userCachePublicAge = time.time()
|
||||
else:
|
||||
response = self.userCachePublic
|
||||
elif (public is False and
|
||||
hasattr(self, 'username') and
|
||||
hasattr(self, 'password')):
|
||||
elif (
|
||||
public is False and hasattr(self, "username") and hasattr(self, "password")
|
||||
):
|
||||
if (time.time() - self.userCacheAge) >= self.timeout:
|
||||
response = requests.get(self.server+"/emby/Users",
|
||||
headers=self.header,
|
||||
params={'Username': self.username,
|
||||
'Pw': self.password})
|
||||
response = requests.get(
|
||||
self.server + "/emby/Users",
|
||||
headers=self.header,
|
||||
params={"Username": self.username, "Pw": self.password},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
response = response.json()
|
||||
self.userCache = response
|
||||
@@ -99,19 +118,19 @@ class Jellyfin:
|
||||
else:
|
||||
try:
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.getUsers(username, id, public)
|
||||
return self.getUsers(username, userId, public)
|
||||
except self.AuthenticationError:
|
||||
raise self.AuthenticationRequiredError
|
||||
else:
|
||||
response = self.userCache
|
||||
else:
|
||||
raise self.AuthenticationRequiredError
|
||||
if username == "all" and id == "all":
|
||||
if username == "all" and userId == "all":
|
||||
return response
|
||||
elif id == "all":
|
||||
elif userId == "all":
|
||||
match = False
|
||||
for user in response:
|
||||
if user['Name'] == username:
|
||||
if user["Name"] == username:
|
||||
match = True
|
||||
return user
|
||||
if not match:
|
||||
@@ -119,12 +138,13 @@ class Jellyfin:
|
||||
else:
|
||||
match = False
|
||||
for user in response:
|
||||
if user['Id'] == id:
|
||||
if user["Id"] == userId:
|
||||
match = True
|
||||
return user
|
||||
if not match:
|
||||
raise self.UserNotFoundError
|
||||
def authenticate(self, username, password):
|
||||
|
||||
def authenticate(self, username: str, password: str):
|
||||
"""
|
||||
Authenticates by name with Jellyfin.
|
||||
|
||||
@@ -133,125 +153,154 @@ class Jellyfin:
|
||||
"""
|
||||
self.username = username
|
||||
self.password = password
|
||||
response = requests.post(self.server+"/emby/Users/AuthenticateByName",
|
||||
headers=self.header,
|
||||
params={'Username': self.username,
|
||||
'Pw': self.password})
|
||||
response = requests.post(
|
||||
self.server + "/emby/Users/AuthenticateByName",
|
||||
headers=self.header,
|
||||
params={"Username": self.username, "Pw": self.password},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
json = response.json()
|
||||
self.userId = json['User']['Id']
|
||||
self.accessToken = json['AccessToken']
|
||||
self.userId = json["User"]["Id"]
|
||||
self.accessToken = json["AccessToken"]
|
||||
self.auth = "MediaBrowser "
|
||||
self.auth += f"Client={self.client}, "
|
||||
self.auth += f"Device={self.device}, "
|
||||
self.auth += f"DeviceId={self.deviceId}, "
|
||||
self.auth += f"Version={self.version}"
|
||||
self.auth += f", Token={self.accessToken}"
|
||||
self.header['X-Emby-Authorization'] = self.auth
|
||||
self.header["X-Emby-Authorization"] = self.auth
|
||||
self.info = requests.get(
|
||||
self.server + "/System/Info", headers=self.header
|
||||
).json()
|
||||
return True
|
||||
else:
|
||||
raise self.AuthenticationError
|
||||
def setPolicy(self, userId, policy):
|
||||
|
||||
def setPolicy(self, userId: str, policy: dict):
|
||||
"""
|
||||
Sets a user's policy (Admin rights, Library Access, etc.) by user ID.
|
||||
|
||||
:param userId: ID of the user to modify.
|
||||
:param policy: User policy in dictionary form.
|
||||
"""
|
||||
return requests.post(self.server+"/Users/"+userId+"/Policy",
|
||||
headers=self.header,
|
||||
params=policy)
|
||||
def newUser(self, username, password):
|
||||
for user in self.getUsers():
|
||||
if user['Name'] == username:
|
||||
return requests.post(
|
||||
self.server + "/Users/" + userId + "/Policy",
|
||||
headers=self.header,
|
||||
params=policy,
|
||||
)
|
||||
|
||||
def newUser(self, username: str, password: str):
|
||||
for user in self.getUsers(public=False):
|
||||
if user["Name"] == username:
|
||||
raise self.UserExistsError
|
||||
response = requests.post(self.server+"/emby/Users/New",
|
||||
headers=self.header,
|
||||
params={'Name': username,
|
||||
'Password': password})
|
||||
response = requests.post(
|
||||
self.server + "/emby/Users/New",
|
||||
headers=self.header,
|
||||
params={"Name": username, "Password": password},
|
||||
)
|
||||
if response.status_code == 401:
|
||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
||||
if hasattr(self, "username") and hasattr(self, "password"):
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.newUser(username, password)
|
||||
else:
|
||||
raise self.AuthenticationRequiredError
|
||||
return response
|
||||
def getViewOrder(self, userId, public=True):
|
||||
|
||||
def getViewOrder(self, userId: str, public: bool = True):
|
||||
if not public:
|
||||
param = '?IncludeHidden=true'
|
||||
param = "?IncludeHidden=true"
|
||||
else:
|
||||
param = ''
|
||||
views = requests.get(self.server+"/Users/"+userId+"/Views"+param,
|
||||
headers=self.header).json()['Items']
|
||||
param = ""
|
||||
views = requests.get(
|
||||
self.server + "/Users/" + userId + "/Views" + param, headers=self.header
|
||||
).json()["Items"]
|
||||
orderedViews = []
|
||||
for library in views:
|
||||
orderedViews.append(library['Id'])
|
||||
orderedViews.append(library["Id"])
|
||||
return orderedViews
|
||||
def setConfiguration(self, userId, configuration):
|
||||
|
||||
def setConfiguration(self, userId: str, configuration: dict):
|
||||
"""
|
||||
Sets a user's configuration (Settings the user can change themselves).
|
||||
:param userId: ID of the user to modify.
|
||||
:param configuration: Configuration to write in dictionary form.
|
||||
"""
|
||||
resp = requests.post(self.server+"/Users/"+userId+"/Configuration",
|
||||
headers=self.header,
|
||||
params=configuration)
|
||||
if (resp.status_code == 200 or
|
||||
resp.status_code == 204):
|
||||
resp = requests.post(
|
||||
self.server + "/Users/" + userId + "/Configuration",
|
||||
headers=self.header,
|
||||
params=configuration,
|
||||
)
|
||||
if resp.status_code == 200 or resp.status_code == 204:
|
||||
return True
|
||||
elif resp.status_code == 401:
|
||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
||||
if hasattr(self, "username") and hasattr(self, "password"):
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.setConfiguration(userId, configuration)
|
||||
else:
|
||||
raise self.AuthenticationRequiredError
|
||||
else:
|
||||
raise self.UnknownError
|
||||
def getConfiguration(self, username="all", id="all"):
|
||||
|
||||
def getConfiguration(self, username: str = "all", userId: str = "all"):
|
||||
"""
|
||||
Gets a user's Configuration. This can also be found in getUsers if
|
||||
public is set to False.
|
||||
:param username: The user's username.
|
||||
:param id: The user's ID.
|
||||
:param userId: The user's ID.
|
||||
"""
|
||||
return self.getUsers(username=username,
|
||||
id=id,
|
||||
public=False)['Configuration']
|
||||
def getDisplayPreferences(self, userId):
|
||||
return self.getUsers(username=username, userId=userId, public=False)[
|
||||
"Configuration"
|
||||
]
|
||||
|
||||
def getDisplayPreferences(self, userId: str):
|
||||
"""
|
||||
Gets a user's Display Preferences (Home layout).
|
||||
:param userId: The user's ID.
|
||||
"""
|
||||
resp = requests.get((self.server+"/DisplayPreferences/usersettings" +
|
||||
"?userId="+userId+"&client=emby"),
|
||||
headers=self.header)
|
||||
resp = requests.get(
|
||||
(
|
||||
self.server
|
||||
+ "/DisplayPreferences/usersettings"
|
||||
+ "?userId="
|
||||
+ userId
|
||||
+ "&client=emby"
|
||||
),
|
||||
headers=self.header,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
elif resp.status_code == 401:
|
||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
||||
if hasattr(self, "username") and hasattr(self, "password"):
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.getDisplayPreferences(userId)
|
||||
else:
|
||||
raise self.AuthenticationRequiredError
|
||||
else:
|
||||
raise self.UnknownError
|
||||
def setDisplayPreferences(self, userId, preferences):
|
||||
|
||||
def setDisplayPreferences(self, userId: str, preferences: dict):
|
||||
"""
|
||||
Sets a user's Display Preferences (Home layout).
|
||||
:param userId: The user's ID.
|
||||
:param preferences: The preferences to set.
|
||||
"""
|
||||
tempheader = self.header
|
||||
tempheader['Content-type'] = 'application/json'
|
||||
resp = requests.post((self.server+"/DisplayPreferences/usersettings" +
|
||||
"?userId="+userId+"&client=emby"),
|
||||
headers=tempheader,
|
||||
json=preferences)
|
||||
if (resp.status_code == 200 or
|
||||
resp.status_code == 204):
|
||||
tempheader["Content-type"] = "application/json"
|
||||
resp = requests.post(
|
||||
(
|
||||
self.server
|
||||
+ "/DisplayPreferences/usersettings"
|
||||
+ "?userId="
|
||||
+ userId
|
||||
+ "&client=emby"
|
||||
),
|
||||
headers=tempheader,
|
||||
json=preferences,
|
||||
)
|
||||
if resp.status_code == 200 or resp.status_code == 204:
|
||||
return True
|
||||
elif resp.status_code == 401:
|
||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
||||
if hasattr(self, "username") and hasattr(self, "password"):
|
||||
self.authenticate(self.username, self.password)
|
||||
return self.setDisplayPreferences(userId, preferences)
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# from flask import g
|
||||
# Handles authentication
|
||||
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from itsdangerous import (TimedJSONWebSignatureSerializer
|
||||
as Serializer, BadSignature, SignatureExpired)
|
||||
from itsdangerous import (
|
||||
TimedJSONWebSignatureSerializer as Serializer,
|
||||
BadSignature,
|
||||
SignatureExpired,
|
||||
)
|
||||
from passlib.apps import custom_app_context as pwd_context
|
||||
import uuid
|
||||
from jellyfin_accounts import config, app, g
|
||||
@@ -11,13 +13,16 @@ from jellyfin_accounts import auth_log as log
|
||||
from jellyfin_accounts.jf_api import Jellyfin
|
||||
from jellyfin_accounts.web_api import jf
|
||||
|
||||
auth_jf = Jellyfin(config['jellyfin']['server'],
|
||||
config['jellyfin']['client'],
|
||||
config['jellyfin']['version'],
|
||||
config['jellyfin']['device'],
|
||||
config['jellyfin']['device_id'] + '_authClient')
|
||||
auth_jf = Jellyfin(
|
||||
config["jellyfin"]["server"],
|
||||
config["jellyfin"]["client"],
|
||||
config["jellyfin"]["version"],
|
||||
config["jellyfin"]["device"],
|
||||
config["jellyfin"]["device_id"] + "_authClient",
|
||||
)
|
||||
|
||||
class Account():
|
||||
|
||||
class Account:
|
||||
def __init__(self, username=None, password=None):
|
||||
self.username = username
|
||||
if password is not None:
|
||||
@@ -25,10 +30,12 @@ class Account():
|
||||
self.id = str(uuid.uuid4())
|
||||
self.jf = False
|
||||
elif username is not None:
|
||||
jf.authenticate(config['jellyfin']['username'],
|
||||
config['jellyfin']['password'])
|
||||
self.id = jf.getUsers(self.username, public=False)['Id']
|
||||
jf.authenticate(
|
||||
config["jellyfin"]["username"], config["jellyfin"]["password"]
|
||||
)
|
||||
self.id = jf.getUsers(self.username, public=False)["Id"]
|
||||
self.jf = True
|
||||
|
||||
def verify_password(self, password):
|
||||
if not self.jf:
|
||||
return pwd_context.verify(password, self.password_hash)
|
||||
@@ -37,59 +44,60 @@ class Account():
|
||||
return auth_jf.authenticate(self.username, password)
|
||||
except Jellyfin.AuthenticationError:
|
||||
return False
|
||||
|
||||
def generate_token(self, expiration=1200):
|
||||
s = Serializer(app.config['SECRET_KEY'], expires_in=expiration)
|
||||
s = Serializer(app.config["SECRET_KEY"], expires_in=expiration)
|
||||
log.debug(self.id)
|
||||
return s.dumps({ 'id': self.id })
|
||||
return s.dumps({"id": self.id})
|
||||
|
||||
@staticmethod
|
||||
def verify_token(token, accounts):
|
||||
log.debug(f'verifying token {token}')
|
||||
s = Serializer(app.config['SECRET_KEY'])
|
||||
log.debug(f"verifying token {token}")
|
||||
s = Serializer(app.config["SECRET_KEY"])
|
||||
try:
|
||||
data = s.loads(token)
|
||||
except SignatureExpired:
|
||||
return None
|
||||
except BadSignature:
|
||||
return None
|
||||
if config.getboolean('ui', 'jellyfin_login'):
|
||||
if config.getboolean("ui", "jellyfin_login"):
|
||||
for account in accounts:
|
||||
if data['id'] == accounts[account].id:
|
||||
if data["id"] == accounts[account].id:
|
||||
return account
|
||||
else:
|
||||
return accounts['adminAccount']
|
||||
|
||||
return accounts["adminAccount"]
|
||||
|
||||
|
||||
auth = HTTPBasicAuth()
|
||||
|
||||
accounts = {}
|
||||
|
||||
if config.getboolean('ui', 'jellyfin_login'):
|
||||
log.debug('Using jellyfin for admin authentication')
|
||||
if config.getboolean("ui", "jellyfin_login"):
|
||||
log.debug("Using jellyfin for admin authentication")
|
||||
else:
|
||||
log.debug('Using configured login details for admin authentication')
|
||||
accounts['adminAccount'] = Account(config['ui']['username'],
|
||||
config['ui']['password'])
|
||||
log.debug("Using configured login details for admin authentication")
|
||||
accounts["adminAccount"] = Account(
|
||||
config["ui"]["username"], config["ui"]["password"]
|
||||
)
|
||||
|
||||
|
||||
@auth.verify_password
|
||||
def verify_password(username, password):
|
||||
user = None
|
||||
verified = False
|
||||
log.debug('Verifying auth')
|
||||
if config.getboolean('ui', 'jellyfin_login'):
|
||||
log.debug("Verifying auth")
|
||||
if config.getboolean("ui", "jellyfin_login"):
|
||||
try:
|
||||
jf_user = jf.getUsers(username, public=False)
|
||||
id = jf_user['Id']
|
||||
id = jf_user["Id"]
|
||||
user = accounts[id]
|
||||
except KeyError:
|
||||
if config.getboolean('ui', 'admin_only'):
|
||||
if jf_user['Policy']['IsAdministrator']:
|
||||
if config.getboolean("ui", "admin_only"):
|
||||
if jf_user["Policy"]["IsAdministrator"]:
|
||||
user = Account(username)
|
||||
accounts[id] = user
|
||||
else:
|
||||
log.debug(f'User {username} not admin.')
|
||||
log.debug(f"User {username} not admin.")
|
||||
return False
|
||||
else:
|
||||
user = Account(username)
|
||||
@@ -98,23 +106,22 @@ def verify_password(username, password):
|
||||
user = Account().verify_token(username, accounts)
|
||||
if user:
|
||||
verified = True
|
||||
if user in accounts:
|
||||
user = accounts[user]
|
||||
if not user:
|
||||
log.debug(f'User {username} not found on Jellyfin')
|
||||
log.debug(f"User {username} not found on Jellyfin")
|
||||
return False
|
||||
else:
|
||||
user = accounts['adminAccount']
|
||||
verified = Account().verify_token(username, accounts)
|
||||
user = accounts["adminAccount"]
|
||||
verified = Account().verify_token(username, accounts)
|
||||
if not verified:
|
||||
if username == user.username and user.verify_password(password):
|
||||
g.user = user
|
||||
log.debug("HTTPAuth Allowed")
|
||||
return True
|
||||
return user
|
||||
else:
|
||||
log.debug("HTTPAuth Denied")
|
||||
return False
|
||||
g.user = user
|
||||
log.debug("HTTPAuth Allowed")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
return user
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Watches Jellyfin for password resets and sends emails.
|
||||
import time
|
||||
import json
|
||||
from watchdog.observers import Observer
|
||||
@@ -8,7 +9,6 @@ from jellyfin_accounts import config, data_store
|
||||
from jellyfin_accounts import email_log as log
|
||||
|
||||
|
||||
|
||||
class Watcher:
|
||||
def __init__(self, dir):
|
||||
self.observer = Observer()
|
||||
@@ -20,13 +20,13 @@ class Watcher:
|
||||
try:
|
||||
self.observer.start()
|
||||
except NotADirectoryError:
|
||||
log.error(f'Directory {self.dir} does not exist')
|
||||
log.error(f"Directory {self.dir} does not exist")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(5)
|
||||
except:
|
||||
self.observer.stop()
|
||||
log.info('Watchdog stopped')
|
||||
log.info("Watchdog stopped")
|
||||
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
@@ -34,33 +34,35 @@ class Handler(FileSystemEventHandler):
|
||||
def on_any_event(event):
|
||||
if event.is_directory:
|
||||
return None
|
||||
elif (event.event_type == 'modified' and
|
||||
'passwordreset' in event.src_path):
|
||||
log.debug(f'Password reset file: {event.src_path}')
|
||||
elif event.event_type == "modified" and "passwordreset" in event.src_path:
|
||||
log.debug(f"Password reset file: {event.src_path}")
|
||||
time.sleep(1)
|
||||
with open(event.src_path, 'r') as f:
|
||||
with open(event.src_path, "r") as f:
|
||||
reset = json.load(f)
|
||||
log.info(f'New password reset for {reset["UserName"]}')
|
||||
try:
|
||||
id = jf.getUsers(reset['UserName'], public=False)['Id']
|
||||
id = jf.getUsers(reset["UserName"], public=False)["Id"]
|
||||
address = data_store.emails[id]
|
||||
if address != '':
|
||||
method = config['email']['method']
|
||||
if method == 'mailgun':
|
||||
if address != "":
|
||||
method = config["email"]["method"]
|
||||
if method == "mailgun":
|
||||
email = Mailgun(address)
|
||||
elif method == 'smtp':
|
||||
elif method == "smtp":
|
||||
email = Smtp(address)
|
||||
if email.construct_reset(reset):
|
||||
email.send()
|
||||
else:
|
||||
raise IndexError
|
||||
except (FileNotFoundError,
|
||||
json.decoder.JSONDecodeError,
|
||||
IndexError) as e:
|
||||
err = f'{address}: Failed: ' + type(e).__name__
|
||||
except (
|
||||
FileNotFoundError,
|
||||
json.decoder.JSONDecodeError,
|
||||
IndexError,
|
||||
) as e:
|
||||
err = f"{address}: Failed: " + type(e).__name__
|
||||
log.error(err)
|
||||
|
||||
|
||||
def start():
|
||||
log.info(f'Monitoring {config["password_resets"]["watch_directory"]}')
|
||||
w = Watcher(config['password_resets']['watch_directory'])
|
||||
w = Watcher(config["password_resets"]["watch_directory"])
|
||||
w.run()
|
||||
|
||||
@@ -1,50 +1,42 @@
|
||||
# Views and endpoints for the initial setup
|
||||
from flask import request, jsonify, render_template
|
||||
from configparser import RawConfigParser
|
||||
from jellyfin_accounts.jf_api import Jellyfin
|
||||
from jellyfin_accounts import config, config_path, app, first_run
|
||||
from jellyfin_accounts import config, config_path, app, first_run, resp
|
||||
from jellyfin_accounts import web_log as log
|
||||
import os
|
||||
|
||||
if first_run:
|
||||
def resp(success=True, code=500):
|
||||
if success:
|
||||
r = jsonify({'success': True})
|
||||
r.status_code = 200
|
||||
else:
|
||||
r = jsonify({'success': False})
|
||||
r.status_code = code
|
||||
return r
|
||||
|
||||
def tempJF(server):
|
||||
return Jellyfin(server,
|
||||
config['jellyfin']['client'],
|
||||
config['jellyfin']['version'],
|
||||
config['jellyfin']['device'] + '_temp',
|
||||
config['jellyfin']['device_id'] + '_temp')
|
||||
return Jellyfin(
|
||||
server,
|
||||
config["jellyfin"]["client"],
|
||||
config["jellyfin"]["version"],
|
||||
config["jellyfin"]["device"] + "_temp",
|
||||
config["jellyfin"]["device_id"] + "_temp",
|
||||
)
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template('404.html'), 404
|
||||
return render_template("404.html"), 404
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def setup():
|
||||
return render_template('setup.html')
|
||||
return render_template("setup.html")
|
||||
|
||||
|
||||
@app.route('/<path:path>')
|
||||
@app.route("/<path:path>")
|
||||
def static_proxy(path):
|
||||
if 'html' not in path:
|
||||
if "html" not in path:
|
||||
return app.send_static_file(path)
|
||||
else:
|
||||
return render_template('404.html'), 404
|
||||
return render_template("404.html"), 404
|
||||
|
||||
|
||||
@app.route('/modifyConfig', methods=['POST'])
|
||||
@app.route("/modifyConfig", methods=["POST"])
|
||||
def modifyConfig():
|
||||
log.info('Config modification requested')
|
||||
log.info("Config modification requested")
|
||||
data = request.get_json()
|
||||
temp_config = RawConfigParser(comment_prefixes='/',
|
||||
allow_no_value=True)
|
||||
temp_config = RawConfigParser(comment_prefixes="/", allow_no_value=True)
|
||||
temp_config.read(config_path)
|
||||
for section in data:
|
||||
if section in temp_config:
|
||||
@@ -52,24 +44,23 @@ if first_run:
|
||||
if item in temp_config[section]:
|
||||
temp_config[section][item] = data[section][item]
|
||||
data[section][item] = True
|
||||
log.debug(f'{section}/{item} modified')
|
||||
log.debug(f"{section}/{item} modified")
|
||||
else:
|
||||
data[section][item] = False
|
||||
log.debug(f'{section}/{item} does not exist in config')
|
||||
with open(config_path, 'w') as config_file:
|
||||
log.debug(f"{section}/{item} does not exist in config")
|
||||
with open(config_path, "w") as config_file:
|
||||
temp_config.write(config_file)
|
||||
log.debug('Config written')
|
||||
log.debug("Config written")
|
||||
# ugly exit, sorry
|
||||
os._exit(1)
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/testJF', methods=['GET', 'POST'])
|
||||
@app.route("/testJF", methods=["GET", "POST"])
|
||||
def testJF():
|
||||
data = request.get_json()
|
||||
tempjf = tempJF(data['jfHost'])
|
||||
tempjf = tempJF(data["jfHost"])
|
||||
try:
|
||||
tempjf.authenticate(data['jfUser'],
|
||||
data['jfPassword'])
|
||||
tempjf.authenticate(data["jfUser"], data["jfPassword"])
|
||||
tempjf.getUsers(public=False)
|
||||
return resp()
|
||||
except:
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
# Password validation
|
||||
specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')',
|
||||
'<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']']
|
||||
|
||||
|
||||
class PasswordValidator:
|
||||
def __init__(self, min_length, upper, lower, number, special):
|
||||
self.criteria = {'characters': int(min_length),
|
||||
'uppercase characters': int(upper),
|
||||
'lowercase characters': int(lower),
|
||||
'numbers': int(number),
|
||||
'special characters': int(special)}
|
||||
self.criteria = {
|
||||
"characters": int(min_length),
|
||||
"uppercase characters": int(upper),
|
||||
"lowercase characters": int(lower),
|
||||
"numbers": int(number),
|
||||
"special characters": int(special),
|
||||
}
|
||||
|
||||
def validate(self, password):
|
||||
count = {'characters': 0,
|
||||
'uppercase characters': 0,
|
||||
'lowercase characters': 0,
|
||||
'numbers': 0,
|
||||
'special characters': 0}
|
||||
count = {
|
||||
"characters": 0,
|
||||
"uppercase characters": 0,
|
||||
"lowercase characters": 0,
|
||||
"numbers": 0,
|
||||
"special characters": 0,
|
||||
}
|
||||
for c in password:
|
||||
count['characters'] += 1
|
||||
count["characters"] += 1
|
||||
if c.isupper():
|
||||
count['uppercase characters'] += 1
|
||||
count["uppercase characters"] += 1
|
||||
elif c.islower():
|
||||
count['lowercase characters'] += 1
|
||||
count["lowercase characters"] += 1
|
||||
elif c.isnumeric():
|
||||
count['numbers'] += 1
|
||||
count["numbers"] += 1
|
||||
elif c in specials:
|
||||
count['special characters'] += 1
|
||||
count["special characters"] += 1
|
||||
for criterion in count:
|
||||
if count[criterion] < self.criteria[criterion]:
|
||||
count[criterion] = False
|
||||
else:
|
||||
count[criterion] = True
|
||||
return count
|
||||
|
||||
def getCriteria(self):
|
||||
lines = {}
|
||||
for criterion in self.criteria:
|
||||
@@ -42,8 +50,3 @@ class PasswordValidator:
|
||||
text += criterion
|
||||
lines[criterion] = text
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,70 +1,97 @@
|
||||
import json
|
||||
# Web views
|
||||
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 config, app, g, css_file, data_store
|
||||
from jellyfin_accounts import web_log as log
|
||||
from jellyfin_accounts.web_api import checkInvite, validator
|
||||
|
||||
|
||||
def bsVersion():
|
||||
if config.getboolean("ui", "bs5"):
|
||||
return 5
|
||||
return 4
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template('404.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage=config['ui']['contact_message']), 404
|
||||
return (
|
||||
render_template(
|
||||
"404.html",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def admin():
|
||||
# return app.send_static_file('admin.html')
|
||||
return render_template('admin.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage='',
|
||||
email_enabled=config.getboolean(
|
||||
'invite_emails', 'enabled'))
|
||||
return render_template(
|
||||
"admin.html",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
contactMessage="",
|
||||
email_enabled=config.getboolean("invite_emails", "enabled"),
|
||||
)
|
||||
|
||||
|
||||
@app.route('/<path:path>')
|
||||
@app.route("/<path:path>")
|
||||
def static_proxy(path):
|
||||
if 'html' not in path:
|
||||
if "html" not in path:
|
||||
if "admin.js" in path:
|
||||
return (
|
||||
render_template(
|
||||
"admin.js",
|
||||
bsVersion=bsVersion(),
|
||||
css_file=css_file,
|
||||
notifications=config.getboolean("notifications", "enabled"),
|
||||
),
|
||||
200,
|
||||
{"Content-Type": "text/javascript"},
|
||||
)
|
||||
return app.send_static_file(path)
|
||||
return render_template('404.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage=config['ui']['contact_message']), 404
|
||||
return (
|
||||
render_template(
|
||||
"404.html",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/invite/<path:path>')
|
||||
@app.route("/invite/<path:path>")
|
||||
def inviteProxy(path):
|
||||
if checkInvite(path):
|
||||
log.info(f'Invite {path} used to request form')
|
||||
log.info(f"Invite {path} used to request form")
|
||||
try:
|
||||
email = data_store.invites[path]['email']
|
||||
email = data_store.invites[path]["email"]
|
||||
except KeyError:
|
||||
email = ''
|
||||
return render_template('form.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage=config['ui']['contact_message'],
|
||||
helpMessage=config['ui']['help_message'],
|
||||
successMessage=config['ui']['success_message'],
|
||||
jfLink=config['jellyfin']['public_server'],
|
||||
validate=config.getboolean(
|
||||
'password_validation',
|
||||
'enabled'),
|
||||
requirements=validator.getCriteria(),
|
||||
email=email)
|
||||
elif 'admin.html' not in path and 'admin.html' not in path:
|
||||
email = ""
|
||||
return render_template(
|
||||
"form.html",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
helpMessage=config["ui"]["help_message"],
|
||||
successMessage=config["ui"]["success_message"],
|
||||
jfLink=config["jellyfin"]["public_server"],
|
||||
validate=config.getboolean("password_validation", "enabled"),
|
||||
requirements=validator().getCriteria(),
|
||||
email=email,
|
||||
username=(not config.getboolean("email", "no_username")),
|
||||
)
|
||||
elif "admin.html" not in path and "admin.html" not in path:
|
||||
return app.send_static_file(path)
|
||||
else:
|
||||
log.debug('Attempted use of invalid invite')
|
||||
return render_template('invalidCode.html',
|
||||
css_href=css['href'],
|
||||
css_integrity=css['integrity'],
|
||||
css_crossorigin=css['crossorigin'],
|
||||
contactMessage=config['ui']['contact_message'])
|
||||
log.debug("Attempted use of invalid invite")
|
||||
return render_template(
|
||||
"invalidCode.html",
|
||||
bs5=config.getboolean("ui", "bs5"),
|
||||
css_file=css_file,
|
||||
contactMessage=config["ui"]["contact_message"],
|
||||
)
|
||||
|
||||
@@ -1,69 +1,119 @@
|
||||
# A bit of a mess, but mostly does API endpoints and a couple compatability fixes
|
||||
from flask import request, jsonify
|
||||
from jellyfin_accounts.jf_api import Jellyfin
|
||||
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,
|
||||
app,
|
||||
g,
|
||||
data_store,
|
||||
resp,
|
||||
configparser,
|
||||
config_base_path,
|
||||
)
|
||||
from jellyfin_accounts.email import Mailgun, Smtp
|
||||
from jellyfin_accounts import web_log as log
|
||||
from jellyfin_accounts.validate_password import PasswordValidator
|
||||
|
||||
def resp(success=True, code=500):
|
||||
if success:
|
||||
r = jsonify({'success': True})
|
||||
if code == 500:
|
||||
r.status_code = 200
|
||||
else:
|
||||
r.status_code = code
|
||||
else:
|
||||
r = jsonify({'success': False})
|
||||
r.status_code = code
|
||||
return r
|
||||
|
||||
def checkInvite(code, delete=False):
|
||||
def format_datetime(dt):
|
||||
result = dt.strftime(config["email"]["date_format"])
|
||||
if config.getboolean("email", "use_24h"):
|
||||
result += f' {dt.strftime("%H:%M")}'
|
||||
else:
|
||||
result += f' {dt.strftime("%I:%M %p")}'
|
||||
return result
|
||||
|
||||
|
||||
def checkInvite(code, used=False, username=None):
|
||||
current_time = datetime.datetime.now()
|
||||
invites = dict(data_store.invites)
|
||||
match = False
|
||||
for invite in invites:
|
||||
expiry = datetime.datetime.strptime(invites[invite]['valid_till'],
|
||||
'%Y-%m-%dT%H:%M:%S.%f')
|
||||
if current_time >= expiry:
|
||||
log.debug(f'Housekeeping: Deleting old invite {invite}')
|
||||
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 or (
|
||||
"no-limit" not in invites[invite] and invites[invite]["remaining-uses"] < 1
|
||||
):
|
||||
log.debug(f"Housekeeping: Deleting expired invite {invite}")
|
||||
if (
|
||||
config.getboolean("notifications", "enabled")
|
||||
and "notify" in invites[invite]
|
||||
):
|
||||
for address in invites[invite]["notify"]:
|
||||
if "notify-expiry" in invites[invite]["notify"][address]:
|
||||
if invites[invite]["notify"][address]["notify-expiry"]:
|
||||
method = config["email"]["method"]
|
||||
if method == "mailgun":
|
||||
email = Mailgun(address)
|
||||
elif method == "smtp":
|
||||
email = Smtp(address)
|
||||
if email.construct_expiry(
|
||||
{"code": invite, "expiry": expiry}
|
||||
):
|
||||
email.send()
|
||||
del data_store.invites[invite]
|
||||
elif invite == code:
|
||||
match = True
|
||||
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
|
||||
|
||||
jf = Jellyfin(config['jellyfin']['server'],
|
||||
config['jellyfin']['client'],
|
||||
config['jellyfin']['version'],
|
||||
config['jellyfin']['device'],
|
||||
config['jellyfin']['device_id'])
|
||||
|
||||
jf = Jellyfin(
|
||||
config["jellyfin"]["server"],
|
||||
config["jellyfin"]["client"],
|
||||
config["jellyfin"]["version"],
|
||||
config["jellyfin"]["device"],
|
||||
config["jellyfin"]["device_id"],
|
||||
)
|
||||
|
||||
from jellyfin_accounts.login import auth
|
||||
|
||||
jf_address = config['jellyfin']['server']
|
||||
jf_address = config["jellyfin"]["server"]
|
||||
success = False
|
||||
for i in range(3):
|
||||
try:
|
||||
jf.authenticate(config['jellyfin']['username'],
|
||||
config['jellyfin']['password'])
|
||||
jf.authenticate(config["jellyfin"]["username"], config["jellyfin"]["password"])
|
||||
success = True
|
||||
log.info(f'Successfully authenticated with {jf_address}')
|
||||
log.info(f"Successfully authenticated with {jf_address}")
|
||||
break
|
||||
except Jellyfin.AuthenticationError:
|
||||
log.error(f'Failed to authenticate with {jf_address}, Retrying...')
|
||||
log.error(f"Failed to authenticate with {jf_address}, Retrying...")
|
||||
time.sleep(5)
|
||||
|
||||
if not success:
|
||||
log.error('Could not authenticate after 3 tries.')
|
||||
log.error("Could not authenticate after 3 tries.")
|
||||
exit()
|
||||
|
||||
# Temporary fixes below.
|
||||
|
||||
|
||||
def switchToIds():
|
||||
try:
|
||||
with open(config['files']['emails'], 'r') as f:
|
||||
with open(config["files"]["emails"], "r") as f:
|
||||
emails = json.load(f)
|
||||
except (FileNotFoundError, json.decoder.JSONDecodeError):
|
||||
emails = {}
|
||||
@@ -72,220 +122,371 @@ def switchToIds():
|
||||
match = False
|
||||
for key in emails:
|
||||
for user in users:
|
||||
if user['Name'] == key:
|
||||
if user["Name"] == key:
|
||||
match = True
|
||||
new_emails[user['Id']] = emails[key]
|
||||
elif user['Id'] == key:
|
||||
new_emails[user['Id']] = emails[key]
|
||||
new_emails[user["Id"]] = emails[key]
|
||||
elif user["Id"] == key:
|
||||
new_emails[user["Id"]] = emails[key]
|
||||
if match:
|
||||
from pathlib import Path
|
||||
email_file = Path(config['files']['emails']).name
|
||||
log.info((f'{email_file} modified to use userID instead of ' +
|
||||
'usernames. These will be used in future.'))
|
||||
|
||||
email_file = Path(config["files"]["emails"]).name
|
||||
log.info(
|
||||
(
|
||||
f"{email_file} modified to use userID instead of "
|
||||
+ "usernames. These will be used in future."
|
||||
)
|
||||
)
|
||||
emails = new_emails
|
||||
with open(config['files']['emails'], 'w') as f:
|
||||
with open(config["files"]["emails"], "w") as f:
|
||||
f.write(json.dumps(emails, indent=4))
|
||||
|
||||
|
||||
# Temporary, switches emails.json over from using Usernames to User IDs.
|
||||
switchToIds()
|
||||
|
||||
if config.getboolean('password_validation', 'enabled'):
|
||||
validator = PasswordValidator(config['password_validation']['min_length'],
|
||||
config['password_validation']['upper'],
|
||||
config['password_validation']['lower'],
|
||||
config['password_validation']['number'],
|
||||
config['password_validation']['special'])
|
||||
else:
|
||||
validator = PasswordValidator(0, 0, 0, 0, 0)
|
||||
|
||||
from packaging import version
|
||||
|
||||
if (
|
||||
version.parse(jf.info["Version"]) >= version.parse("10.6.0")
|
||||
and bool(data_store.user_template) is not False
|
||||
):
|
||||
if (
|
||||
data_store.user_template["AuthenticationProviderId"]
|
||||
== "Emby.Server.Implementations.Library.DefaultAuthenticationProvider"
|
||||
):
|
||||
log.info("Updating user_template for Jellyfin >= 10.6.0")
|
||||
data_store.user_template[
|
||||
"AuthenticationProviderId"
|
||||
] = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider"
|
||||
if (
|
||||
data_store.user_template["PasswordResetProviderId"]
|
||||
== "Emby.Server.Implementations.Library.DefaultPasswordResetProvider"
|
||||
):
|
||||
data_store.user_template[
|
||||
"PasswordResetProviderId"
|
||||
] = "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider"
|
||||
|
||||
|
||||
@app.route('/newUser', methods=['POST'])
|
||||
def validator():
|
||||
if config.getboolean("password_validation", "enabled"):
|
||||
return PasswordValidator(
|
||||
config["password_validation"]["min_length"],
|
||||
config["password_validation"]["upper"],
|
||||
config["password_validation"]["lower"],
|
||||
config["password_validation"]["number"],
|
||||
config["password_validation"]["special"],
|
||||
)
|
||||
return PasswordValidator(0, 0, 0, 0, 0)
|
||||
|
||||
|
||||
@app.route("/newUser", methods=["POST"])
|
||||
def newUser():
|
||||
data = request.get_json()
|
||||
log.debug('Attempted newUser')
|
||||
if checkInvite(data['code']):
|
||||
validation = validator.validate(data['password'])
|
||||
log.debug("Attempted newUser")
|
||||
if checkInvite(data["code"]):
|
||||
validation = validator().validate(data["password"])
|
||||
valid = True
|
||||
for criterion in validation:
|
||||
if validation[criterion] is False:
|
||||
valid = False
|
||||
if valid:
|
||||
log.debug('User password valid')
|
||||
log.debug("User password valid")
|
||||
try:
|
||||
user = jf.newUser(data['username'],
|
||||
data['password'])
|
||||
user = jf.newUser(data["username"], data["password"])
|
||||
except Jellyfin.UserExistsError:
|
||||
error = f'User already exists named {data["username"]}'
|
||||
log.debug(error)
|
||||
return jsonify({'error': error})
|
||||
return jsonify({"error": error})
|
||||
except:
|
||||
return jsonify({'error': 'Unknown error'})
|
||||
checkInvite(data['code'], delete=True)
|
||||
return jsonify({"error": "Unknown error"})
|
||||
invites = dict(data_store.invites)
|
||||
checkInvite(data["code"], used=True, username=data["username"])
|
||||
if (
|
||||
config.getboolean("notifications", "enabled")
|
||||
and "notify" in invites[data["code"]]
|
||||
):
|
||||
for address in invites[data["code"]]["notify"]:
|
||||
if "notify-creation" in invites[data["code"]]["notify"][address]:
|
||||
if invites[data["code"]]["notify"][address]["notify-creation"]:
|
||||
method = config["email"]["method"]
|
||||
if method == "mailgun":
|
||||
email = Mailgun(address)
|
||||
elif method == "smtp":
|
||||
email = Smtp(address)
|
||||
if email.construct_created(
|
||||
{
|
||||
"code": data["code"],
|
||||
"username": data["username"],
|
||||
"created": datetime.datetime.now(),
|
||||
}
|
||||
):
|
||||
email.send()
|
||||
if user.status_code == 200:
|
||||
try:
|
||||
policy = data_store.user_template
|
||||
if policy != {}:
|
||||
jf.setPolicy(user.json()['Id'], policy)
|
||||
jf.setPolicy(user.json()["Id"], policy)
|
||||
else:
|
||||
log.debug('user policy was blank')
|
||||
log.debug("user policy was blank")
|
||||
except:
|
||||
log.error('Failed to set new user policy')
|
||||
log.error("Failed to set new user policy")
|
||||
try:
|
||||
configuration = data_store.user_configuration
|
||||
displayprefs = data_store.user_displayprefs
|
||||
if configuration != {} and displayprefs != {}:
|
||||
if jf.setConfiguration(user.json()['Id'],
|
||||
configuration):
|
||||
jf.setDisplayPreferences(user.json()['Id'],
|
||||
displayprefs)
|
||||
log.debug('Set homescreen layout.')
|
||||
if jf.setConfiguration(user.json()["Id"], configuration):
|
||||
jf.setDisplayPreferences(user.json()["Id"], displayprefs)
|
||||
log.debug("Set homescreen layout.")
|
||||
else:
|
||||
log.debug('user configuration and/or ' +
|
||||
'displayprefs were blank')
|
||||
log.debug("user configuration and/or displayprefs were blank")
|
||||
except:
|
||||
log.error('Failed to set new user homescreen layout')
|
||||
if config.getboolean('password_resets', 'enabled'):
|
||||
data_store.emails[user.json()['Id']] = data['email']
|
||||
log.debug('Email address stored')
|
||||
log.info('New user created')
|
||||
log.error("Failed to set new user homescreen layout")
|
||||
if config.getboolean("password_resets", "enabled"):
|
||||
data_store.emails[user.json()["Id"]] = data["email"]
|
||||
log.debug("Email address stored")
|
||||
log.info("New user created")
|
||||
else:
|
||||
log.error(f'New user creation failed: {user.status_code}')
|
||||
log.error(f"New user creation failed: {user.status_code}")
|
||||
return resp(False)
|
||||
else:
|
||||
log.debug('User password invalid')
|
||||
log.debug("User password invalid")
|
||||
return jsonify(validation)
|
||||
else:
|
||||
log.debug('Attempted newUser unauthorized')
|
||||
log.debug("Attempted newUser unauthorized")
|
||||
return resp(False, code=401)
|
||||
|
||||
|
||||
@app.route('/generateInvite', methods=['POST'])
|
||||
@app.route("/generateInvite", methods=["POST"])
|
||||
@auth.login_required
|
||||
def generateInvite():
|
||||
current_time = datetime.datetime.now()
|
||||
data = request.get_json()
|
||||
delta = datetime.timedelta(hours=int(data['hours']),
|
||||
minutes=int(data['minutes']))
|
||||
delta = datetime.timedelta(
|
||||
days=int(data["days"]), hours=int(data["hours"]), minutes=int(data["minutes"])
|
||||
)
|
||||
invite_code = secrets.token_urlsafe(16)
|
||||
invite = {}
|
||||
log.debug(f'Creating new invite: {invite_code}')
|
||||
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')
|
||||
if 'email' in data and config.getboolean('invite_emails', 'enabled'):
|
||||
address = data['email']
|
||||
invite['email'] = address
|
||||
log.info(f'Sending invite to {address}')
|
||||
method = config['email']['method']
|
||||
if method == 'mailgun':
|
||||
invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f")
|
||||
if "email" in data and config.getboolean("invite_emails", "enabled"):
|
||||
address = data["email"]
|
||||
invite["email"] = address
|
||||
log.info(f"Sending invite to {address}")
|
||||
method = config["email"]["method"]
|
||||
if method == "mailgun":
|
||||
from jellyfin_accounts.email import Mailgun
|
||||
|
||||
email = Mailgun(address)
|
||||
elif method == 'smtp':
|
||||
elif method == "smtp":
|
||||
from jellyfin_accounts.email import Smtp
|
||||
|
||||
email = Smtp(address)
|
||||
email.construct_invite({'expiry': valid_till,
|
||||
'code': invite_code})
|
||||
email.construct_invite({"expiry": valid_till, "code": invite_code})
|
||||
response = email.send()
|
||||
if response is False or type(response) != bool:
|
||||
invite['email'] = f'Failed to send to {address}'
|
||||
invite["email"] = f"Failed to send to {address}"
|
||||
if config.getboolean("notifications", "enabled"):
|
||||
if "notify-creation" in data:
|
||||
invite["notify-creation"] = data["notify-creation"]
|
||||
if "notify-expiry" in data:
|
||||
invite["notify-expiry"] = data["notify-expiry"]
|
||||
data_store.invites[invite_code] = invite
|
||||
log.info(f'New invite created: {invite_code}')
|
||||
log.info(f"New invite created: {invite_code}")
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/getInvites', methods=['GET'])
|
||||
@app.route("/getInvites", methods=["GET"])
|
||||
@auth.login_required
|
||||
def getInvites():
|
||||
log.debug('Invites requested')
|
||||
log.debug("Invites requested")
|
||||
current_time = datetime.datetime.now()
|
||||
invites = dict(data_store.invites)
|
||||
for code in invites:
|
||||
checkInvite(code)
|
||||
invites = dict(data_store.invites)
|
||||
response = {'invites': []}
|
||||
response = {"invites": []}
|
||||
for code in invites:
|
||||
expiry = datetime.datetime.strptime(invites[code]['valid_till'],
|
||||
'%Y-%m-%dT%H:%M:%S.%f')
|
||||
expiry = datetime.datetime.strptime(
|
||||
invites[code]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
|
||||
)
|
||||
valid_for = expiry - current_time
|
||||
invite = {'code': code,
|
||||
'hours': valid_for.seconds//3600,
|
||||
'minutes': (valid_for.seconds//60) % 60}
|
||||
if 'email' in invites[code]:
|
||||
invite['email'] = invites[code]['email']
|
||||
response['invites'].append(invite)
|
||||
invite = {
|
||||
"code": code,
|
||||
"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"]
|
||||
if "notify" in invites[code]:
|
||||
if config.getboolean("ui", "jellyfin_login"):
|
||||
address = data_store.emails[g.user.id]
|
||||
else:
|
||||
address = config["ui"]["email"]
|
||||
if address in invites[code]["notify"]:
|
||||
if "notify-expiry" in invites[code]["notify"][address]:
|
||||
invite["notify-expiry"] = invites[code]["notify"][address][
|
||||
"notify-expiry"
|
||||
]
|
||||
if "notify-creation" in invites[code]["notify"][address]:
|
||||
invite["notify-creation"] = invites[code]["notify"][address][
|
||||
"notify-creation"
|
||||
]
|
||||
response["invites"].append(invite)
|
||||
return jsonify(response)
|
||||
|
||||
@app.route('/deleteInvite', methods=['POST'])
|
||||
|
||||
@app.route("/deleteInvite", methods=["POST"])
|
||||
@auth.login_required
|
||||
def deleteInvite():
|
||||
code = request.get_json()['code']
|
||||
code = request.get_json()["code"]
|
||||
invites = dict(data_store.invites)
|
||||
if code in invites:
|
||||
del data_store.invites[code]
|
||||
log.info(f'Invite deleted: {code}')
|
||||
log.info(f"Invite deleted: {code}")
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/getToken')
|
||||
@app.route("/getToken")
|
||||
@auth.login_required
|
||||
def get_token():
|
||||
token = g.user.generate_token()
|
||||
return jsonify({'token': token.decode('ascii')})
|
||||
return jsonify({"token": token.decode("ascii")})
|
||||
|
||||
|
||||
@app.route('/getUsers', methods=['GET'])
|
||||
@app.route("/getUsers", methods=["GET"])
|
||||
@auth.login_required
|
||||
def getUsers():
|
||||
log.debug('User and email list requested')
|
||||
response = {'users': []}
|
||||
log.debug("User and email list requested")
|
||||
response = {"users": []}
|
||||
users = jf.getUsers(public=False)
|
||||
emails = data_store.emails
|
||||
for user in users:
|
||||
entry = {'name': user['Name']}
|
||||
if user['Id'] in emails:
|
||||
entry['email'] = emails[user['Id']]
|
||||
response['users'].append(entry)
|
||||
entry = {"name": user["Name"]}
|
||||
if user["Id"] in emails:
|
||||
entry["email"] = emails[user["Id"]]
|
||||
response["users"].append(entry)
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@app.route('/modifyUsers', methods=['POST'])
|
||||
@app.route("/modifyUsers", methods=["POST"])
|
||||
@auth.login_required
|
||||
def modifyUsers():
|
||||
data = request.get_json()
|
||||
log.debug('Email list modification requested')
|
||||
log.debug("Email list modification requested")
|
||||
for key in data:
|
||||
uid = jf.getUsers(key, public=False)['Id']
|
||||
uid = jf.getUsers(key, public=False)["Id"]
|
||||
data_store.emails[uid] = data[key]
|
||||
log.debug(f'Email for user "{key}" modified')
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/setDefaults', methods=['POST'])
|
||||
@app.route("/setDefaults", methods=["POST"])
|
||||
@auth.login_required
|
||||
def setDefaults():
|
||||
data = request.get_json()
|
||||
username = data['username']
|
||||
log.debug(f'Storing default settings from user {username}')
|
||||
username = data["username"]
|
||||
log.debug(f"Storing default settings from user {username}")
|
||||
try:
|
||||
user = jf.getUsers(username=username,
|
||||
public=False)
|
||||
user = jf.getUsers(username=username, public=False)
|
||||
except Jellyfin.UserNotFoundError:
|
||||
log.error(f'Storing defaults failed: Couldn\'t find user {username}')
|
||||
log.error(f"Storing defaults failed: Couldn't find user {username}")
|
||||
return resp(False)
|
||||
uid = user['Id']
|
||||
policy = user['Policy']
|
||||
uid = user["Id"]
|
||||
policy = user["Policy"]
|
||||
data_store.user_template = policy
|
||||
if data['homescreen']:
|
||||
configuration = user['Configuration']
|
||||
if data["homescreen"]:
|
||||
configuration = user["Configuration"]
|
||||
try:
|
||||
displayprefs = jf.getDisplayPreferences(uid)
|
||||
data_store.user_configuration = configuration
|
||||
data_store.user_displayprefs = displayprefs
|
||||
except:
|
||||
log.error('Storing defaults failed: ' +
|
||||
'couldn\'t store homescreen layout')
|
||||
log.error("Storing defaults failed: " + "couldn't store homescreen layout")
|
||||
return resp(False)
|
||||
return resp()
|
||||
|
||||
import jellyfin_accounts.setup
|
||||
|
||||
|
||||
@app.route("/modifyConfig", methods=["POST"])
|
||||
@auth.login_required
|
||||
def modifyConfig():
|
||||
global config
|
||||
log.info("Config modification requested")
|
||||
data = request.get_json()
|
||||
temp_config = configparser.RawConfigParser(
|
||||
comment_prefixes="/", allow_no_value=True
|
||||
)
|
||||
temp_config.read(config_path)
|
||||
for section in data:
|
||||
if section in temp_config:
|
||||
for item in data[section]:
|
||||
temp_config[section][item] = data[section][item]
|
||||
data[section][item] = True
|
||||
log.debug(f"{section}/{item} modified")
|
||||
with open(config_path, "w") as config_file:
|
||||
temp_config.write(config_file)
|
||||
config.trigger_reload()
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
@app.route("/setNotify", methods=["POST"])
|
||||
@auth.login_required
|
||||
def setNotify():
|
||||
data = request.get_json()
|
||||
change = False
|
||||
for code in data:
|
||||
for key in data[code]:
|
||||
if key in ["notify-expiry", "notify-creation"]:
|
||||
inv = data_store.invites[code]
|
||||
if config.getboolean("ui", "jellyfin_login"):
|
||||
address = data_store.emails[g.user.id]
|
||||
else:
|
||||
address = config["ui"]["email"]
|
||||
if "notify" not in inv:
|
||||
inv["notify"] = {}
|
||||
if address not in inv["notify"]:
|
||||
inv["notify"][address] = {}
|
||||
inv["notify"][address][key] = data[code][key]
|
||||
log.debug(f"{code}: Notification settings changed")
|
||||
change = True
|
||||
if change:
|
||||
data_store.invites[code] = inv
|
||||
return resp()
|
||||
return resp(success=False)
|
||||
|
||||
8
jf-accounts.service
Normal file
8
jf-accounts.service
Normal 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
|
||||
47
mail/created.mjml
Normal file
47
mail/created.mjml
Normal file
@@ -0,0 +1,47 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="rgba(255,255,255,0.8)" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<h3>User Created</h3>
|
||||
<p>A user was created using code {{ code }}.</p>
|
||||
</mj-text>
|
||||
<mj-table mj-class="text" container-background-color="#242424">
|
||||
<tr style="text-align: left;">
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
<tr style="font-style: italic; text-align: left; color: rgb(153,153,153);">
|
||||
<th>{{ username }}</th>
|
||||
<th>{{ address }}</th>
|
||||
<th>{{ time }}</th>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
Notification emails can be toggled on the admin dashboard.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
7
mail/created.txt
Normal file
7
mail/created.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
A user was created using code {{ code }}.
|
||||
|
||||
Name: {{ username }}
|
||||
Address: {{ address }}
|
||||
Time: {{ time }}
|
||||
|
||||
Note: Notification emails can be toggled on the admin dashboard.
|
||||
40
mail/email.mjml
Normal file
40
mail/email.mjml
Normal file
@@ -0,0 +1,40 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="rgba(255,255,255,0.8)" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>Hi {{ username }},</p>
|
||||
<p> Someone has recently requested a password reset on Jellyfin.</p>
|
||||
<p>If this was you, enter the below pin into the prompt.</p>
|
||||
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
|
||||
<p>If this wasn't you, please ignore this email.</p>
|
||||
</mj-text>
|
||||
<mj-button mj-class="blue bold">{{ pin }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
10
mail/email.txt
Normal file
10
mail/email.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
Hi {{ username }},
|
||||
|
||||
Someone has recently requests a password reset on Jellyfin.
|
||||
If this was you, enter the below pin into the prompt.
|
||||
This code will expire on {{ expiry_date }}, at {{ expiry_time }} , which is in {{ expires_in }}.
|
||||
If this wasn't you, please ignore this email.
|
||||
|
||||
PIN: {{ pin }}
|
||||
|
||||
{{ message }}
|
||||
36
mail/expired.mjml
Normal file
36
mail/expired.mjml
Normal file
@@ -0,0 +1,36 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="rgba(255,255,255,0.8)" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<h3>Invite Expired.</h3>
|
||||
<p>Code {{ code }} expired at {{ expiry }}.</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
Notification emails can be toggled on the admin dashboard.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
5
mail/expired.txt
Normal file
5
mail/expired.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Invite expired.
|
||||
|
||||
Code {{ code }} expired at {{ expiry }}.
|
||||
|
||||
Note: Notification emails can be toggled on the admin dashboard.
|
||||
34
mail/generate.py
Executable file
34
mail/generate.py
Executable file
@@ -0,0 +1,34 @@
|
||||
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 mjml in [f for f in local_path.iterdir() if f.is_file() and 'mjml' in f.suffix]:
|
||||
print(f'Compiling {mjml.name}')
|
||||
fname = mjml.with_suffix(".html")
|
||||
runcmd(f'{str(node_bin / "mjml")} {str(mjml)} -o {str(fname)}')
|
||||
if fname.is_file():
|
||||
print('Done.')
|
||||
|
||||
html = [f for f in local_path.iterdir() if f.is_file() and 'html' in f.suffix]
|
||||
|
||||
output = local_path.parent / 'jellyfin_accounts' / 'data'
|
||||
|
||||
for f in html:
|
||||
shutil.copy(str(f),
|
||||
str(output / f.name))
|
||||
print(f'Copied {f.name} to {str(output / f.name)}')
|
||||
txtfile = f.with_suffix('.txt')
|
||||
if txtfile.is_file():
|
||||
shutil.copy(str(txtfile),
|
||||
str(output / txtfile.name))
|
||||
print(f'Copied {txtfile.name} to {str(output / txtfile.name)}')
|
||||
else:
|
||||
print(f'Warning: {txtfile.name} does not exist. Text versions of emails should be supplied.')
|
||||
|
||||
39
mail/invite-email.mjml
Normal file
39
mail/invite-email.mjml
Normal file
@@ -0,0 +1,39 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-class name="bg" background-color="#101010" />
|
||||
<mj-class name="bg2" background-color="#242424" />
|
||||
<mj-class name="text" color="rgba(255,255,255,0.8)" />
|
||||
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
|
||||
<mj-class name="secondary" color="rgb(153,153,153)" />
|
||||
<mj-class name="blue" background-color="rgb(0,164,220)" />
|
||||
</mj-attributes>
|
||||
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg">
|
||||
<mj-column>
|
||||
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
|
||||
<p>Hi,</p>
|
||||
<h3>You've been invited to Jellyfin.</h3>
|
||||
<p>To join, click the button below.</p>
|
||||
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
|
||||
</mj-text>
|
||||
<mj-button mj-class="blue bold" href="{{ invite_link }}">Setup your account</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section mj-class="bg2">
|
||||
<mj-column>
|
||||
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
|
||||
{{ message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
8
mail/invite-email.txt
Normal file
8
mail/invite-email.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Hi,
|
||||
You've been invited to Jellyfin.
|
||||
To join, follow the below link.
|
||||
This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.
|
||||
|
||||
{{ invite_link }}
|
||||
|
||||
{{ message }}
|
||||
2447
package-lock.json
generated
Normal file
2447
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "jellyfin-accounts",
|
||||
"version": "1.0.0",
|
||||
"description": "This is only used for grabbing scss build dependencies, and isn't a real package.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/hrfee/jellyfin-accounts.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/hrfee/jellyfin-accounts/issues"
|
||||
},
|
||||
"homepage": "https://github.com/hrfee/jellyfin-accounts#readme",
|
||||
"dependencies": {
|
||||
"autoprefixer": "^9.8.5",
|
||||
"bootstrap": "^5.0.0-alpha1",
|
||||
"bootstrap4": "npm:bootstrap@^4.5.0",
|
||||
"clean-css-cli": "^4.3.0",
|
||||
"lodash": "^4.17.19",
|
||||
"mjml": "^4.6.3",
|
||||
"postcss-cli": "^7.1.1"
|
||||
}
|
||||
}
|
||||
321
poetry.lock
generated
321
poetry.lock
generated
@@ -1,3 +1,45 @@
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
name = "appdirs"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.4.4"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Classes Without Boilerplate"
|
||||
name = "attrs"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "19.3.0"
|
||||
|
||||
[package.extras]
|
||||
azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
|
||||
dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
|
||||
docs = ["sphinx", "zope.interface"]
|
||||
tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "The uncompromising code formatter."
|
||||
name = "black"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "19.10b0"
|
||||
|
||||
[package.dependencies]
|
||||
appdirs = "*"
|
||||
attrs = ">=18.1.0"
|
||||
click = ">=6.5"
|
||||
pathspec = ">=0.6,<1"
|
||||
regex = "*"
|
||||
toml = ">=0.9.4"
|
||||
typed-ast = ">=1.4.0"
|
||||
|
||||
[package.extras]
|
||||
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
@@ -33,18 +75,6 @@ optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "7.1.2"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Updated configparser from Python 3.8 for Python 2.6+."
|
||||
name = "configparser"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
version = "5.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
|
||||
testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-black-multipy", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
@@ -89,11 +119,19 @@ description = "Basic and Digest HTTP authentication for Flask routes"
|
||||
name = "flask-httpauth"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "4.1.0"
|
||||
version = "3.3.0"
|
||||
|
||||
[package.dependencies]
|
||||
Flask = "*"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Lightweight in-process concurrent programming"
|
||||
name = "greenlet"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.4.16"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
@@ -124,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."
|
||||
@@ -132,6 +181,37 @@ optional = false
|
||||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
|
||||
version = "1.1.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "MessagePack (de)serializer."
|
||||
name = "msgpack"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.0.0"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Transition packgage for pynvim"
|
||||
name = "neovim"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.3.1"
|
||||
|
||||
[package.dependencies]
|
||||
pynvim = ">=0.3.1"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Core utilities for Python packages"
|
||||
name = "packaging"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "20.4"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2"
|
||||
six = "*"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "comprehensive password hashing framework supporting over 30 schemes"
|
||||
@@ -146,6 +226,14 @@ bcrypt = ["bcrypt (>=3.1.0)"]
|
||||
build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.0)"]
|
||||
totp = ["cryptography"]
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
name = "pathspec"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "File system general utilities"
|
||||
@@ -162,6 +250,22 @@ optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
version = "2.20"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Python client to neovim"
|
||||
name = "pynvim"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.4.1"
|
||||
|
||||
[package.dependencies]
|
||||
greenlet = "*"
|
||||
msgpack = ">=0.5.0"
|
||||
|
||||
[package.extras]
|
||||
pyuv = ["pyuv (>=1.0.0)"]
|
||||
test = ["pytest (>=3.4.0)"]
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python wrapper module around the OpenSSL library"
|
||||
@@ -178,6 +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"
|
||||
@@ -197,6 +309,14 @@ optional = false
|
||||
python-versions = "*"
|
||||
version = "2020.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "Alternative regular expression module, to replace re."
|
||||
name = "regex"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "2020.6.8"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "Python HTTP for Humans."
|
||||
@@ -223,6 +343,33 @@ 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"
|
||||
name = "toml"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "0.10.1"
|
||||
|
||||
[[package]]
|
||||
category = "dev"
|
||||
description = "a fork of Python 2 and 3 ast modules with type comment support"
|
||||
name = "typed-ast"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
version = "1.4.1"
|
||||
|
||||
[[package]]
|
||||
category = "main"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
@@ -275,10 +422,22 @@ dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-
|
||||
watchdog = ["watchdog"]
|
||||
|
||||
[metadata]
|
||||
content-hash = "721be13a1e348d7e424529ba8466b9e2408df2cd97ab45e7e0d2f665b3213879"
|
||||
content-hash = "fa8b5fb1ded41b673b8062a2bfc6467e6a484ff62b578147bec001d7d9d8ca16"
|
||||
python-versions = "^3.6"
|
||||
|
||||
[metadata.files]
|
||||
appdirs = [
|
||||
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
|
||||
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
|
||||
]
|
||||
attrs = [
|
||||
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
|
||||
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
|
||||
]
|
||||
black = [
|
||||
{file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
|
||||
{file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
|
||||
]
|
||||
certifi = [
|
||||
{file = "certifi-2020.4.5.2-py2.py3-none-any.whl", hash = "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc"},
|
||||
{file = "certifi-2020.4.5.2.tar.gz", hash = "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1"},
|
||||
@@ -321,10 +480,6 @@ click = [
|
||||
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
|
||||
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
|
||||
]
|
||||
configparser = [
|
||||
{file = "configparser-5.0.0-py3-none-any.whl", hash = "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd"},
|
||||
{file = "configparser-5.0.0.tar.gz", hash = "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1"},
|
||||
]
|
||||
cryptography = [
|
||||
{file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"},
|
||||
{file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"},
|
||||
@@ -351,8 +506,27 @@ flask = [
|
||||
{file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"},
|
||||
]
|
||||
flask-httpauth = [
|
||||
{file = "Flask-HTTPAuth-4.1.0.tar.gz", hash = "sha256:9e028e4375039a49031eb9ecc40be4761f0540476040f6eff329a31dabd4d000"},
|
||||
{file = "Flask_HTTPAuth-4.1.0-py2.py3-none-any.whl", hash = "sha256:29e0288869a213c7387f0323b6bf2c7191584fb1da8aa024d9af118e5cd70de7"},
|
||||
{file = "Flask-HTTPAuth-3.3.0.tar.gz", hash = "sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9"},
|
||||
{file = "Flask_HTTPAuth-3.3.0-py2.py3-none-any.whl", hash = "sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72"},
|
||||
]
|
||||
greenlet = [
|
||||
{file = "greenlet-0.4.16-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872"},
|
||||
{file = "greenlet-0.4.16-cp27-cp27m-win32.whl", hash = "sha256:df7de669cbf21de4b04a3ffc9920bc8426cab4c61365fa84d79bf97401a8bef7"},
|
||||
{file = "greenlet-0.4.16-cp27-cp27m-win_amd64.whl", hash = "sha256:1429dc183b36ec972055e13250d96e174491559433eb3061691b446899b87384"},
|
||||
{file = "greenlet-0.4.16-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5ea034d040e6ab1d2ae04ab05a3f37dbd719c4dee3804b13903d4cc794b1336e"},
|
||||
{file = "greenlet-0.4.16-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c196a5394c56352e21cb7224739c6dd0075b69dd56f758505951d1d8d68cf8a9"},
|
||||
{file = "greenlet-0.4.16-cp35-cp35m-win32.whl", hash = "sha256:1000038ba0ea9032948e2156a9c15f5686f36945e8f9906e6b8db49f358e7b52"},
|
||||
{file = "greenlet-0.4.16-cp35-cp35m-win_amd64.whl", hash = "sha256:1b805231bfb7b2900a16638c3c8b45c694334c811f84463e52451e00c9412691"},
|
||||
{file = "greenlet-0.4.16-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e5db19d4a7d41bbeb3dd89b49fc1bc7e6e515b51bbf32589c618655a0ebe0bf0"},
|
||||
{file = "greenlet-0.4.16-cp36-cp36m-win32.whl", hash = "sha256:eac2a3f659d5f41d6bbfb6a97733bc7800ea5e906dc873732e00cebb98cec9e4"},
|
||||
{file = "greenlet-0.4.16-cp36-cp36m-win_amd64.whl", hash = "sha256:7eed31f4efc8356e200568ba05ad645525f1fbd8674f1e5be61a493e715e3873"},
|
||||
{file = "greenlet-0.4.16-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:682328aa576ec393c1872615bcb877cf32d800d4a2f150e1a5dc7e56644010b1"},
|
||||
{file = "greenlet-0.4.16-cp37-cp37m-win32.whl", hash = "sha256:3a35e33902b2e6079949feed7a2dafa5ac6f019da97bd255842bb22de3c11bf5"},
|
||||
{file = "greenlet-0.4.16-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b2a984bbfc543d144d88caad6cc7ff4a71be77102014bd617bd88cfb038727"},
|
||||
{file = "greenlet-0.4.16-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:d83c1d38658b0f81c282b41238092ed89d8f93c6e342224ab73fb39e16848721"},
|
||||
{file = "greenlet-0.4.16-cp38-cp38-win32.whl", hash = "sha256:e695ac8c3efe124d998230b219eb51afb6ef10524a50b3c45109c4b77a8a3a92"},
|
||||
{file = "greenlet-0.4.16-cp38-cp38-win_amd64.whl", hash = "sha256:133ba06bad4e5f2f8bf6a0ac434e0fd686df749a86b3478903b92ec3a9c0c90b"},
|
||||
{file = "greenlet-0.4.16.tar.gz", hash = "sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
|
||||
@@ -366,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"},
|
||||
@@ -401,10 +590,41 @@ markupsafe = [
|
||||
{file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
|
||||
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
|
||||
]
|
||||
msgpack = [
|
||||
{file = "msgpack-1.0.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:cec8bf10981ed70998d98431cd814db0ecf3384e6b113366e7f36af71a0fca08"},
|
||||
{file = "msgpack-1.0.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aa5c057eab4f40ec47ea6f5a9825846be2ff6bf34102c560bad5cad5a677c5be"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:4233b7f86c1208190c78a525cd3828ca1623359ef48f78a6fea4b91bb995775a"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b3758dfd3423e358bbb18a7cccd1c74228dffa7a697e5be6cb9535de625c0dbf"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:25b3bc3190f3d9d965b818123b7752c5dfb953f0d774b454fd206c18fe384fb8"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-win32.whl", hash = "sha256:e7bbdd8e2b277b77782f3ce34734b0dfde6cbe94ddb74de8d733d603c7f9e2b1"},
|
||||
{file = "msgpack-1.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5dba6d074fac9b24f29aaf1d2d032306c27f04187651511257e7831733293ec2"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:908944e3f038bca67fcfedb7845c4a257c7749bf9818632586b53bcf06ba4b97"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:db685187a415f51d6b937257474ca72199f393dad89534ebbdd7d7a3b000080e"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ea41c9219c597f1d2bf6b374d951d310d58684b5de9dc4bd2976db9e1e22c140"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-win32.whl", hash = "sha256:e35b051077fc2f3ce12e7c6a34cf309680c63a842db3a0616ea6ed25ad20d272"},
|
||||
{file = "msgpack-1.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5bea44181fc8e18eed1d0cd76e355073f00ce232ff9653a0ae88cb7d9e643322"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c901e8058dd6653307906c5f157f26ed09eb94a850dddd989621098d347926ab"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:271b489499a43af001a2e42f42d876bb98ccaa7e20512ff37ca78c8e12e68f84"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7a22c965588baeb07242cb561b63f309db27a07382825fc98aecaf0827c1538e"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-win32.whl", hash = "sha256:002a0d813e1f7b60da599bdf969e632074f9eec1b96cbed8fb0973a63160a408"},
|
||||
{file = "msgpack-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:39c54fdebf5fa4dda733369012c59e7d085ebdfe35b6cf648f09d16708f1be5d"},
|
||||
{file = "msgpack-1.0.0.tar.gz", hash = "sha256:9534d5cc480d4aff720233411a1f765be90885750b07df772380b34c10ecb5c0"},
|
||||
]
|
||||
neovim = [
|
||||
{file = "neovim-0.3.1.tar.gz", hash = "sha256:a6a0e7a5b4433bf4e6ddcbc5c5ff44170be7d84259d002b8e8d8fb4ee78af60f"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
|
||||
{file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"},
|
||||
]
|
||||
passlib = [
|
||||
{file = "passlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177"},
|
||||
{file = "passlib-1.7.2.tar.gz", hash = "sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8"},
|
||||
]
|
||||
pathspec = [
|
||||
{file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"},
|
||||
{file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"},
|
||||
]
|
||||
pathtools = [
|
||||
{file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"},
|
||||
]
|
||||
@@ -412,10 +632,17 @@ pycparser = [
|
||||
{file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
|
||||
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
|
||||
]
|
||||
pynvim = [
|
||||
{file = "pynvim-0.4.1.tar.gz", hash = "sha256:55e918d664654cfa1c9889d3dbe7c63e9f338df5d49471663f78d54c85e84c58"},
|
||||
]
|
||||
pyopenssl = [
|
||||
{file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
|
||||
{file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
|
||||
{file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
|
||||
]
|
||||
python-dateutil = [
|
||||
{file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"},
|
||||
{file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"},
|
||||
@@ -424,6 +651,29 @@ pytz = [
|
||||
{file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"},
|
||||
{file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"},
|
||||
]
|
||||
regex = [
|
||||
{file = "regex-2020.6.8-cp27-cp27m-win32.whl", hash = "sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c"},
|
||||
{file = "regex-2020.6.8-cp27-cp27m-win_amd64.whl", hash = "sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-win32.whl", hash = "sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3"},
|
||||
{file = "regex-2020.6.8-cp36-cp36m-win_amd64.whl", hash = "sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-win32.whl", hash = "sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9"},
|
||||
{file = "regex-2020.6.8-cp37-cp37m-win_amd64.whl", hash = "sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-manylinux1_i686.whl", hash = "sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-win32.whl", hash = "sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754"},
|
||||
{file = "regex-2020.6.8-cp38-cp38-win_amd64.whl", hash = "sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5"},
|
||||
{file = "regex-2020.6.8.tar.gz", hash = "sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
|
||||
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
|
||||
@@ -432,6 +682,37 @@ 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"},
|
||||
]
|
||||
typed-ast = [
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
|
||||
{file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
|
||||
{file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
|
||||
{file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
|
||||
{file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
|
||||
{file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
|
||||
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
|
||||
]
|
||||
urllib3 = [
|
||||
{file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"},
|
||||
{file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "jellyfin-accounts"
|
||||
version = "0.2.0"
|
||||
version = "0.3.7"
|
||||
readme = "README.md"
|
||||
description = "A simple account management system for Jellyfin"
|
||||
authors = ["Harvey Tindall <harveyltindall@gmail.com>"]
|
||||
@@ -8,35 +8,44 @@ 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", "jellyfin_accounts/data/*.html"]
|
||||
exclude = ["images/*", "scss/*", "mail/*"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.6"
|
||||
pyopenssl = "^19.1.0"
|
||||
flask = "^1.1.2"
|
||||
flask-httpauth = "^3.3.0"
|
||||
flask-httpauth = ">= 3.3.0"
|
||||
requests = "^2.23.0"
|
||||
itsdangerous = "^1.1.0"
|
||||
passlib = "^1.7.2"
|
||||
pytz = "^2020.1"
|
||||
python-dateutil = "^2.8.1"
|
||||
watchdog = "^0.10.2"
|
||||
configparser = "^5.0.0"
|
||||
waitress = "^1.4.3"
|
||||
packaging = "^20.4"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
|
||||
neovim = "^0.3.1"
|
||||
black = "^19.10b0"
|
||||
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"
|
||||
pre_generate-emails = "task get-npm-deps"
|
||||
generate-emails = "python mail/generate.py"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
|
||||
147
requirements.txt
Normal file
147
requirements.txt
Normal 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
10
scss/README.md
Normal 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
140
scss/bs4/bs4-jf.scss
Normal 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
140
scss/bs5/bs5-jf.scss
Normal 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
35
scss/compile.py
Executable 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
17
scss/get_node_deps.py
Normal 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())}.')
|
||||
|
||||
Reference in New Issue
Block a user