67 Commits

Author SHA1 Message Date
2e20466925 Self-restarting for config changes
When changing settings that need restart, the option is now available to
do it automatically. Functions on linux at least, might need testing on
    windows.
2020-07-20 15:37:19 +01:00
ef8ff531e3 Don't check invites if there aren't any
self-explanatory. Check if the dict is empty before doing anything.
2020-07-18 18:21:36 +01:00
b863706d26 Thread notification emails to avoid slowing UI 2020-07-18 18:20:54 +01:00
7ec8650467 Mention that expiry time is UTC 2020-07-18 18:19:14 +01:00
d5ce6d31c5 Handle FileNotFoundError
I'm guessing watchdog's behaviour changed in an update, as the error
thrown when the watched directory doesn't exist is now
"FileNotFoundError" instead of "NotADirectoryError". It'll handle either
one now.
2020-07-18 18:15:01 +01:00
95989840f1 Load templates directly, Account for daylight savings time
password reset files use UTC always, which I did not realize when
writing the password reset handler as the UK uses UTC and we weren't in
daylight savings time. The expiry time is now correctly handled as UTC.

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

poetry run task compile-css

before a:

poetry build

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

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

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

14
.gitignore vendored
View File

@@ -4,11 +4,21 @@ 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
jellyfin_accounts/data/*.txt

152
README.md
View File

@@ -1,20 +1,20 @@
# ![jellyfin-accounts](images/jellyfin-accounts-banner-wide.svg)
# ![jellyfin-accounts](https://raw.githubusercontent.com/hrfee/jellyfin-accounts/bs5/images/jellyfin-accounts-banner-wide.svg)
A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin).
* Provides a web interface for creating invite codes, and a simple account creation form
* Provides a web interface for creating/sending invites
* Sends out emails when a user requests a password reset
* Uses a basic python jellyfin API client for communication with the server.
* Uses [Flask](https://github.com/pallets/flask), [HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth), [itsdangerous](https://github.com/pallets/itsdangerous), and [Waitress](https://github.com/Pylons/waitress)
* Frontend uses [Bootstrap](https://getbootstrap.com), [jQuery](https://jquery.com) and [jQuery-serialize-object](https://github.com/macek/jquery-serialize-object)
* Frontend uses [Bootstrap](https://v5.getbootstrap.com)
* Password resets are handled using smtplib, requests, and [jinja](https://github.com/pallets/jinja)
## Interface
<p align="center">
<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
View 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 =

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

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

@@ -0,0 +1,121 @@
import os
import configparser
import secrets
from pathlib import Path
class Config:
"""
Configuration object that can automatically reload modified settings.
Behaves mostly like a dictionary.
:param file: Path to config.ini, where parameters are set.
:param instance: Used to identify specific jf-accounts instances in environment variables.
:param data_dir: Path to directory with config, invites, templates, etc.
:param local_dir: Path to internally stored config base, emails, etc.
"""
@staticmethod
def load_config(config_path, data_dir, local_dir, log):
# Lord forgive me for this mess
config = configparser.RawConfigParser()
config.read(config_path)
for key in config["files"]:
if config["files"][key] == "":
if key != "custom_css":
log.debug(f"Using default {key}")
config["files"][key] = str(data_dir / (key + ".json"))
for key in ["user_configuration", "user_displayprefs"]:
if key not in config["files"]:
log.debug(f"Using default {key}")
config["files"][key] = str(data_dir / (key + ".json"))
if "no_username" not in config["email"]:
config["email"]["no_username"] = "false"
log.debug("Set no_username to false")
if (
"email_html" not in config["password_resets"]
or config["password_resets"]["email_html"] == ""
):
log.debug("Using default password reset email HTML template")
config["password_resets"]["email_html"] = str(local_dir / "email.html")
if (
"email_text" not in config["password_resets"]
or config["password_resets"]["email_text"] == ""
):
log.debug("Using default password reset email plaintext template")
config["password_resets"]["email_text"] = str(local_dir / "email.txt")
if (
"email_html" not in config["invite_emails"]
or config["invite_emails"]["email_html"] == ""
):
log.debug("Using default invite email HTML template")
config["invite_emails"]["email_html"] = str(local_dir / "invite-email.html")
if (
"email_text" not in config["invite_emails"]
or config["invite_emails"]["email_text"] == ""
):
log.debug("Using default invite email plaintext template")
config["invite_emails"]["email_text"] = str(local_dir / "invite-email.txt")
if (
"public_server" not in config["jellyfin"]
or config["jellyfin"]["public_server"] == ""
):
config["jellyfin"]["public_server"] = config["jellyfin"]["server"]
if "bs5" not in config["ui"] or config["ui"]["bs5"] == "":
config["ui"]["bs5"] = "false"
if (
"expiry_html" not in config["notifications"]
or config["notifications"]["expiry_html"] == ""
):
log.debug("Using default expiry notification HTML template")
config["notifications"]["expiry_html"] = str(local_dir / "expired.html")
if (
"expiry_text" not in config["notifications"]
or config["notifications"]["expiry_text"] == ""
):
log.debug("Using default expiry notification plaintext template")
config["notifications"]["expiry_text"] = str(local_dir / "expired.txt")
if (
"created_html" not in config["notifications"]
or config["notifications"]["created_html"] == ""
):
log.debug("Using default user creation notification HTML template")
config["notifications"]["created_html"] = str(local_dir / "created.html")
if (
"created_text" not in config["notifications"]
or config["notifications"]["created_text"] == ""
):
log.debug("Using default user creation notification plaintext template")
config["notifications"]["created_text"] = str(local_dir / "created.txt")
return config
def __init__(self, file, instance, data_dir, local_dir, log):
self.config_path = Path(file)
self.data_dir = data_dir
self.local_dir = local_dir
self.instance = instance
self.log = log
self.varname = f"JFA_{self.instance}_RELOADCONFIG"
os.environ[self.varname] = "true"
def __getitem__(self, key):
if os.environ[self.varname] == "true":
self.config = Config.load_config(
self.config_path, self.data_dir, self.local_dir, self.log
)
os.environ[self.varname] = "false"
return self.config.__getitem__(key)
def getboolean(self, sect, key):
if os.environ[self.varname] == "true":
self.config = Config.load_config(
self.config_path, self.data_dir, self.local_dir, self.log
)
os.environ[self.varname] = "false"
return self.config.getboolean(sect, key)
def trigger_reload(self):
os.environ[self.varname] = "true"

View File

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

View File

@@ -1,116 +0,0 @@
[jellyfin]
; It is reccommended to create a limited admin account for this program.
username = username
password = password
; Jellyfin server address. Can be public, or local for security purposes.
server = http://jellyfin.local:8096
; Publicly accessible Jellyfin address, used on invite form.
; Leave blank to use the same address as above.
public_server = https://jellyf.in:443
client = jf-accounts
version = 0.1
device = jf-accounts
device_id = jf-accounts-0.1
[ui]
; Set 0.0.0.0 to run localhost
host = 0.0.0.0
port = 8056
; Enable this to use Jellyfin users instead of the below username and pw.
jellyfin_login = true
; Allows only admin users on Jellyfin to access admin page.
admin_only = true
; Username to use on admin page... (leave blank if using jellyfin_login)
username = your username
; ..and its corresponding password (leave blank if using jellyfin_login)
password = your password
debug = false
; Displayed at the bottom of all pages except admin
contact_message = Need help? contact me.
; Displayed at top of form page.
help_message = Enter your details to create an account.
; Displayed when an account is created.
success_message = Your account has been created. Click below to continue to Jellyfin.
[password_validation]
; Enables password validation.
enabled = true
; Min. password length
min_length = 8
; Min. number of uppercase characters
upper = 1
; Min. number of lowercase characters
lower = 0
; Min. number of numbers
number = 1
; Min. number of special characters
special = 0
[email]
; Leave this whole section if you aren't using any email-related features.
use_24h = true
; Date format follows datetime's strftime.
date_format = %d/%m/%y
; Displayed at bottom of emails
message = Need help? contact me.
; Mail methods: mailgun, smtp
method = smtp
; Address to send from
address = jellyfin@jellyf.in
; The name of the sender
from = Jellyfin
[password_resets]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
enabled = true
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
watch_directory = /path/to/jellyfin
; Path to custom email html. If blank, uses the internal template.
email_html =
; Path to alternate plaintext email. If blank, uses the internal template.
email_text =
; Subject of emails
subject = Password Reset - Jellyfin
[invite_emails]
; If enabled, allows one to send an invite directly to an email address.
enabled = true
; Path to custom email html. If blank, uses the internal template.
email_html =
; Path to alternate plaintext email. If blank, uses the internal template.
email_text =
subject = Invite - Jellyfin
; Base url for jf-accounts. This necessary because most will use a reverse proxy, so the program has no other way of knowing what URL to send.
url_base = http://accounts.jellyf.in:8056/invite
[mailgun]
api_url = https://api.mailgun.net...
api_key = your api key
[smtp]
; Choose between ssl_tls and starttls. Your provider should tell you which to use, but generally SSL/TLS is 465, STARTTLS 587
encryption = starttls
server = smtp.jellyf.in
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
port = 465
password = smtp password
[files]
; When the below paths are left blank, files are stored in ~/.jf-accounts/.
; Path to store valid invites.
invites =
; Path to store emails addresses in JSON
emails =
; Path to the user policy template. Can be acquired with get-defaults (jf-accounts -g).
user_template =
; Path to the user configuration template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
user_configuration =
; Path to the user display preferences template (part of homescreen layout). Can be acquired with get-defaults (jf-accounts -g).
user_displayprefs =
; Path to custom bootstrap.css
custom_css =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,451 +0,0 @@
function parseInvite(invite, empty = false) {
if (empty === true) {
return ["None", "", "1"]
} else {
var i = ["", "", "0", invite['email']];
i[0] = invite['code'];
if (invite['hours'] == 0) {
i[1] = invite['minutes'] + 'm';
} else if (invite['minutes'] == 0) {
i[1] = invite['hours'] + 'h';
} else {
i[1] = invite['hours'] + 'h ' + invite['minutes'] + 'm';
}
i[1] = "Expires in " + i[1] + " ";
return i
}
}
function addItem(invite) {
var links = document.getElementById('invites');
var listItem = document.createElement('li');
listItem.id = invite[0]
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
var listCode = document.createElement('div');
listCode.classList.add('d-flex', 'align-items-center', 'text-monospace');
var codeLink = document.createElement('a');
codeLink.setAttribute('style', 'margin-right: 2%;');
codeLink.appendChild(document.createTextNode(invite[0].replace(/-/g, '')));
listCode.appendChild(codeLink);
listItem.appendChild(listCode);
var listRight = document.createElement('div');
listText = document.createElement('span');
listText.id = invite[0] + '_expiry'
listText.appendChild(document.createTextNode(invite[1]));
listRight.appendChild(listText);
if (invite[2] == 0) {
var inviteCode = window.location.href + 'invite/' + invite[0];
codeLink.href = inviteCode;
// listCode.appendChild(document.createTextNode(" "));
var codeCopy = document.createElement('i');
codeCopy.onclick = function(){toClipboard(inviteCode)};
codeCopy.classList.add('fa', 'fa-clipboard');
listCode.appendChild(codeCopy);
if (typeof(invite[3]) != 'undefined') {
var sentTo = document.createElement('span');
sentTo.setAttribute('style', 'color: grey; margin-left: 2%; font-style: italic; font-size: 75%;');
if (invite[3].includes('Failed to send to')) {
sentTo.appendChild(document.createTextNode(invite[3]));
} else {
sentTo.appendChild(document.createTextNode('Sent to ' + invite[3]));
}
listCode.appendChild(sentTo);
};
var listDelete = document.createElement('button');
listDelete.onclick = function(){deleteInvite(invite[0])};
listDelete.classList.add('btn', 'btn-outline-danger');
listDelete.appendChild(document.createTextNode('Delete'));
listRight.appendChild(listDelete);
};
listItem.appendChild(listRight);
links.appendChild(listItem);
};
function updateInvite(invite) {
var expiry = document.getElementById(invite[0] + '_expiry');
expiry.textContent = invite[1];
}
function removeInvite(code) {
var item = document.getElementById(code);
item.parentNode.removeChild(item);
}
function generateInvites(empty = false) {
// document.getElementById('invites').textContent = '';
if (empty === false) {
$.ajax('/getInvites', {
type : 'GET',
dataType : 'json',
contentType: 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
data: { get_param: 'value' },
complete: function(response) {
var data = JSON.parse(response['responseText']);
if (data['invites'].length == 0) {
document.getElementById('invites').textContent = '';
addItem(parseInvite([], true));
} else {
data['invites'].forEach(function(invite) {
var match = false;
var items = document.getElementById('invites').children;
for (var i = 0; i < items.length; i++) {
if (items[i].id == invite['code']) {
match = true;
updateInvite(parseInvite(invite));
};
};
if (match == false) {
addItem(parseInvite(invite));
};
});
var items = document.getElementById('invites').children;
for (var i = 0; i < items.length; i++) {
var exists = false;
data['invites'].forEach(function(invite) {
if (items[i].id == invite['code']) {
exists = true;
}
});
if (exists == false) {
removeInvite(items[i].id);
}
};
};
}
});
} else if (empty === true) {
document.getElementById('invites').textContent = '';
addItem(parseInvite([], true));
};
};
function deleteInvite(code) {
var send = JSON.stringify({ "code": code });
$.ajax('/deleteInvite', {
data : send,
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() { generateInvites(); },
});
};
function addOptions(le, sel) {
for (v = 0; v <= le; v++) {
var opt = document.createElement('option');
opt.appendChild(document.createTextNode(v))
opt.value = v
sel.appendChild(opt)
}
};
function toClipboard(str) {
const el = document.createElement('textarea');
el.value = str;
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
const selected =
document.getSelection().rangeCount > 0
? document.getSelection().getRangeAt(0)
: false;
el.select();
document.execCommand('copy');
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
};
$("form#inviteForm").submit(function() {
var button = document.getElementById('generateSubmit');
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
var send_object = $("form#inviteForm").serializeObject();
if (document.getElementById('send_to_address') != null) {
if (document.getElementById('send_to_address_enabled').checked) {
send_object['email'] = document.getElementById('send_to_address').value;
}
}
var send = JSON.stringify(send_object);
$.ajax('/generateInvite', {
data : send,
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() {
button.textContent = 'Generate';
button.disabled = false;
generateInvites();
},
});
return false;
});
$("form#loginForm").submit(function() {
window.token = "";
var details = $("form#loginForm").serializeObject();
var errorArea = document.getElementById('loginErrorArea');
errorArea.textContent = '';
var button = document.getElementById('loginSubmit');
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
$.ajax('/getToken', {
type : 'GET',
dataType : 'json',
contentType: 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password']));
},
data: { get_param: 'value' },
complete: function(data) {
if (data['status'] == 401) {
button.disabled = false;
button.textContent = 'Login';
var wrongPassword = document.createElement('div');
wrongPassword.classList.add('alert', 'alert-danger');
wrongPassword.setAttribute('role', 'alert');
wrongPassword.appendChild(document.createTextNode('Incorrect username or password.'));
errorArea.appendChild(wrongPassword);
} else {
window.token = JSON.parse(data['responseText'])['token'];
generateInvites();
var interval = setInterval(function() { generateInvites(); }, 60 * 1000);
var hour = document.getElementById('hours');
addOptions(24, hour);
hour.selected = "0";
var minutes = document.getElementById('minutes');
addOptions(59, minutes);
minutes.selected = "30";
$('#login').modal('hide');
}
}
});
return false;
});
document.getElementById('openSettings').onclick = function () {
$('#settingsMenu').modal('show');
}
document.getElementById('openDefaultsWizard').onclick = function () {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
$.ajax('getUsers', {
type : 'GET',
dataType : 'json',
contentType : 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
complete : function(data) {
if (data['status'] == 200) {
var radioList = document.getElementById('defaultUserRadios');
radioList.textContent = '';
if (document.getElementById('setDefaultUser')) {
document.getElementById('setDefaultUser').remove();
};
var users = data['responseJSON']['users'];
for (var i = 0; i < users.length; i++) {
var user = users[i]
var radio = document.createElement('div');
radio.classList.add('radio');
if (i == 0) {
var checked = 'checked';
} else {
var checked = '';
};
radio.innerHTML =
'<label><input type="radio" name="defaultRadios" id="default_' +
user['name'] + '" style="margin-right: 1rem;"' + checked + '>' +
user['name'] + '</label>';
radioList.appendChild(radio);
}
var button = document.getElementById('openDefaultsWizard');
button.disabled = false;
button.innerHTML = 'Set new account defaults';
var submitButton = document.getElementById('storeDefaults');
submitButton.disabled = false;
submitButton.textContent = 'Submit';
if (submitButton.classList.contains('btn-success')) {
submitButton.classList.remove('btn-success');
submitButton.classList.add('btn-primary');
} else if (submitButton.classList.contains('btn-danger')) {
submitButton.classList.remove('btn-danger');
submitButton.classList.add('btn-primary');
}
$('#userDefaults').modal('show');
}
}
});
};
document.getElementById('storeDefaults').onclick = function () {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
var button = document.getElementById('storeDefaults');
var radios = document.getElementsByName('defaultRadios');
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) {
var data = {'username':radios[i].id.slice(8), 'homescreen':false};
if (document.getElementById('storeDefaultHomescreen').checked) {
data['homescreen'] = true;
}
$.ajax('/setDefaults', {
data : JSON.stringify(data),
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() {
button.textContent = 'Success';
if (button.classList.contains('btn-danger')) {
button.classList.remove('btn-danger');
} else if (button.classList.contains('btn-primary')) {
button.classList.remove('btn-primary');
};
button.classList.add('btn-success');
button.disabled = false;
setTimeout(function(){$('#userDefaults').modal('hide');}, 1000);
},
error: function() {
button.textContent = 'Failed';
button.classList.remove('btn-primary');
button.classList.add('btn-danger');
setTimeout(function(){
var button = document.getElementById('storeDefaults');
button.textContent = 'Submit';
button.classList.remove('btn-danger');
button.classList.add('btn-primary');
button.disabled = false;
}, 1000);
}
});
}
}
};
document.getElementById('openUsers').onclick = function () {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
$.ajax('/getUsers', {
type : 'GET',
dataType : 'json',
contentType: 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
data: { get_param: 'value' },
complete : function(data) {
if (data['status'] == 200) {
var list = document.getElementById('userList');
list.textContent = '';
if (document.getElementById('saveUsers')) {
document.getElementById('saveUsers').remove();
};
var users = data['responseJSON']['users'];
for (var i = 0; i < users.length; i++) {
var user = users[i]
var entry = document.createElement('p');
entry.id = 'user_' + user['name'];
entry.appendChild(document.createTextNode(user['name']));
var address = document.createElement('span');
address.setAttribute('style', 'margin-left: 2%; margin-right: 2%; color: grey;');
address.classList.add('addressText');
address.id = 'address_' + user['email'];
if (typeof(user['email']) != 'undefined') {
address.appendChild(document.createTextNode(user['email']));
};
var editButton = document.createElement('i');
editButton.classList.add('fa', 'fa-edit');
editButton.onclick = function() {
this.classList.remove('fa', 'fa-edit');
var input = document.createElement('input');
input.setAttribute('type', 'email');
input.setAttribute('style', 'margin-left: 2%; color: grey;');
var addressElement = this.parentNode.getElementsByClassName('addressText')[0];
if (addressElement.textContent != '') {
input.value = addressElement.textContent;
} else {
input.placeholder = 'Email Address';
};
this.parentNode.replaceChild(input, addressElement);
if (document.getElementById('saveUsers') == null) {
var footer = document.getElementById('userFooter')
var saveUsers = document.createElement('input');
saveUsers.classList.add('btn', 'btn-primary');
saveUsers.setAttribute('type', 'button');
saveUsers.value = 'Save Changes';
saveUsers.id = 'saveUsers';
saveUsers.onclick = function() {
var send = {}
var entries = document.getElementById('userList').children;
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
if (typeof(entry.getElementsByTagName('input')[0]) != 'undefined') {
var name = entry.id.replace(/user_/g, '')
var address = entry.getElementsByTagName('input')[0].value;
send[name] = address
};
};
send = JSON.stringify(send);
$.ajax('/modifyUsers', {
data : send,
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() { $('#users').modal('hide'); },
});
};
footer.appendChild(saveUsers);
};
};
entry.appendChild(address);
entry.appendChild(editButton);
list.appendChild(entry);
};
var button = document.getElementById('openUsers');
button.disabled = false;
button.innerHTML = 'Users <i class="fa fa-user"></i>';
$('#users').modal('show');
};
}
});
};
generateInvites(empty = true);
$("#login").modal('show');

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
function serializeForm(id) {
var form = document.getElementById(id);
var formData = {};
for (var i = 0; i < form.elements.length; i++) {
var el = form.elements[i];
if (el.type != 'submit') {
var name = el.name;
if (name == '') {
name = el.id;
};
switch (el.type) {
case 'checkbox':
formData[name] = el.checked;
break;
case 'text':
case 'password':
case 'select-one':
case 'email':
case 'number':
formData[name] = el.value;
break;
};
};
};
return formData;
};

View File

@@ -13,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));
};

View File

@@ -1,35 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<title>404</title>
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Page not found.</h1>
<p>
{{ contactMessage }}
</p>
</div>
</body>
<title>404</title>
<link rel="stylesheet" type="text/css" href="{{ css_file }}">
{% if not bs5 %}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
{% if bs5 %}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
{% else %}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{% endif %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Page not found.</h1>
<p>
{{ contactMessage }}
</p>
</div>
</body>
</html>

View File

@@ -12,18 +12,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,113 @@
</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. Restart now, later, or cancel?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" id="applyRestarts" data-dismiss="modal">Apply, Restart later</button>
<button type="button" class="btn btn-primary" id="applyAndRestart" data-dismiss="modal">Apply &amp; Restart</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="refreshModal" role="dialog" aria-labelledby="Refresh page notice" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Settings applied.</h5>
</div>
<div class="modal-body">
<p>Refresh the page in a few seconds.</p>
</div>
</div>
</div>
</div>
<div 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 +329,7 @@
<p>{{ contactMessage }}</p>
</div>
</div>
<script src="serialize.js"></script>
<script src="admin.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -12,15 +12,21 @@
<meta name="msapplication-TileColor" content="#603cba">
<meta name="theme-color" content="#ffffff">
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" href="{{ css_file }}">
{% if not bs5 %}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
{% if bs5 %}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
{% else %}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{% endif %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
.pageContainer {
margin: 20%;
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.pageContainer {
@@ -61,24 +67,26 @@
<p class="contactBox">{{ contactMessage }}</p>
<div class="container" id="container">
<div class="row" id="cardContainer">
<div class="col-sm" id="accountForm">
<div class="card bg-light mb-3">
<div class="col-sm">
<div class="card mb-3">
<div class="card-header">Details</div>
<div class="card-body">
<form action="#" method="POST">
<form action="#" method="POST" id="accountForm">
<div class="form-group">
<label for="inputEmail">Email</label>
<input type="email" class="form-control" id="inputEmail" name="email" placeholder="Email" value="{{ email }}" required>
<input type="email" class="form-control" id="{% if username %}inputEmail{% else %}inputUsername{% endif %}" name="{% if username %}email{% else %}username{% endif %}" placeholder="Email" value="{{ email }}" required>
</div>
{% if username %}
<div class="form-group">
<label for="inputUsername">Username</label>
<input type="username" class="form-control" id="inputUsername" name="username" placeholder="Username" required>
</div>
{% endif %}
<div class="form-group">
<label for="inputPassword">Password</label>
<input type="password" class="form-control" id="inputPassword" name="password" placeholder="Password" required>
</div>
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox">
<div class="btn-group" role="group" aria-label="Button & Error" id="errorBox" style="margin-top: 1rem;">
<button type="submit" class="btn btn-outline-primary" id="submitButton">
<span id="createAccount">Create Account</span>
</button>
@@ -89,7 +97,7 @@
</div>
{% if validate %}
<div class="col-sm" id="requirementBox">
<div class="card bg-light mb-3 requirementBox">
<div class="card mb-3 requirementBox">
<div class="card-header">Password Requirements</div>
<div class="card-body">
<ul class="list-group">
@@ -106,7 +114,25 @@
</div>
</div>
</div>
<script src="serialize.js"></script>
<script>
{% if bs5 %}
var bsVersion = 5;
{% else %}
var bsVersion = 4;
{% endif %}
if (bsVersion == 5) {
var successBox = new bootstrap.Modal(document.getElementById('successBox'));
} else if (bsVersion == 4) {
var successBox = {
show : function() {
return $('#successBox').modal('show');
},
hide : function() {
return $('#successBox').modal('hide');
}
};
};
var code = window.location.href.split('/').pop();
function toggleSpinner () {
var submitButton = document.getElementById('submitButton');
@@ -129,26 +155,37 @@
}
submitButton.replaceChild(newSpan, oldSpan);
};
$("form").submit(function() {
document.getElementById('accountForm').onsubmit = function() {
if (document.getElementById('errorMessage')) {
document.getElementById('errorMessage').remove();
}
toggleSpinner();
var send = $("form").serializeObject();
var send = serializeForm('accountForm');
send['code'] = code;
{% if not username %}
send['email'] = send['username'];
{% endif %}
send = JSON.stringify(send);
$.ajax('/newUser', {
data : send,
contentType : 'application/json',
type : 'POST',
crossDomain : true,
complete : function(response){
var req = new XMLHttpRequest();
req.open("POST", "/newUser", true);
req.responseType = 'json';
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = function() {
if (this.readyState == 4) {
toggleSpinner();
var data = response['responseJSON'];
if ('error' in data) {
var text = document.createTextNode(data['error']);
// <div class="alert alert-danger" id="errorBox"></div>
var data = this.response;
if ('error' in data || data['success'] == false) {
if (typeof(data['error']) != 'undefined') {
var errorMessage = data['error'];
} else {
var errorMessage = 'Unknown Error';
}
var text = document.createTextNode(errorMessage);
var error = document.createElement('button');
error.classList.add('btn', 'btn-outline-danger');
error.setAttribute('disabled', '');
error.appendChild(text);
error.id = 'errorMessage';
document.getElementById('errorBox').appendChild(error);
} else {
var valid = true
@@ -172,13 +209,14 @@
};
};
if (valid == true) {
$('#successBox').modal('show');
successBox.show();
};
}
}
});
};
};
};
req.send(send);
return false;
});
};
</script>
</body>
</html>

View File

@@ -1,25 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Invalid Code</title>
<link rel="stylesheet" href="{{ css_href }}" integrity="{{ css_integrity }}" crossorigin="{{ css_crossorigin }}">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Invalid Code.</h1>
<p>The above code is either incorrect, or has expired.</p>
<p>{{ contactMessage }}</p>
</div>
</body>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Invalid Code</title>
<!-- Bootstrap CSS -->
<link rel="stylesheet" type="text/css" href="{{ css_file }}">
{% if not bs5 %}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
{% if bs5 %}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js" integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/" crossorigin="anonymous"></script>
{% else %}
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{% endif %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Invalid Code.</h1>
<p>The above code is either incorrect, or has expired.</p>
<p>{{ contactMessage }}</p>
</div>
</body>
</html>

View File

@@ -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>
@@ -341,7 +355,7 @@
<div class="card-body text-center">
<h5 class="card-title">Finished!</h5>
<p class="card-text">
Press the button below to submit your settings. The program will quit, so run it again, then refresh this page.
Press the button below to submit your settings. The program will restart. Once it's done, refresh this page.
</p>
<button id="submitButton" class="btn btn-primary">Submit</button>
</div>

View File

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

View File

@@ -1,3 +1,4 @@
# Handles everything related to emails
import datetime
import pytz
import requests
@@ -7,176 +8,243 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from pathlib import Path
from dateutil import parser as date_parser
from jinja2 import Environment, FileSystemLoader
from jinja2 import Template
from jellyfin_accounts import config
from jellyfin_accounts import 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})"
)
)
# sp = Path(config["invite_emails"]["email_
# template_loader = FileSystemLoader(searchpath=sp)
# template_loader = PackageLoader("jellyfin_accounts", "data")
# self.template_env = Environment(loader=template_loader)
def pretty_time(self, expiry):
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')
def pretty_time(self, expiry, tzaware=False):
if tzaware:
current_time = datetime.datetime.utcnow().replace(tzinfo=pytz.utc)
else:
log.debug(f'{self.address}: Using 12h time')
time = expiry.strftime('%-I:%M %p')
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")
else:
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()) + '/'
template_loader = FileSystemLoader(searchpath=sp)
template_env = Environment(loader=template_loader)
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)
email_message = config["email"]["message"]
invite_link = config["invite_emails"]["url_base"]
invite_link += "/" + invite["code"]
for key in ["text", "html"]:
fpath = Path(config["invite_emails"]["email_" + key])
with open(fpath, 'r') as f:
template = Template(f.read())
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"]:
fpath = Path(config["notifications"]["expiry_" + key])
with open(fpath, 'r') as f:
template = Template(f.read())
c = template.render(code=invite["code"], expiry=expiry)
self.content[key] = c
log.info(f"{self.address}: {key} constructed")
return True
def construct_created(self, invite):
self.subject = "Notice: User created"
log.debug(f'Constructing user creation notification for {invite["code"]}')
created = format_datetime(invite["created"])
if config.getboolean("email", "no_username"):
email = "n/a"
else:
email = invite["address"]
for key in ["text", "html"]:
fpath = Path(config["notifications"]["created_" + key])
with open(fpath, 'r') as f:
template = Template(f.read())
c = template.render(
code=invite["code"],
username=invite["username"],
address=email,
time=created,
)
self.content[key] = c
log.info(f"{self.address}: {key} constructed")
return True
def construct_reset(self, reset):
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 = expiry.replace(tzinfo=None)
expiry = date_parser.parse(reset["ExpirationDate"])
except:
log.error(f"{self.address}: Couldn't parse expiry time")
return False
current_time = datetime.datetime.now()
current_time = datetime.datetime.utcnow().replace(tzinfo=pytz.utc)
if expiry >= current_time:
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()) + '/'
template_loader = FileSystemLoader(searchpath=sp)
template_env = Environment(loader=template_loader)
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)
log.debug(f"{self.address}: Invite valid")
pretty = self.pretty_time(expiry, tzaware=True)
email_message = config["email"]["message"]
for key in ["text", "html"]:
fpath = Path(config["password_resets"]["email_" + key])
with open(fpath, 'r') as f:
template = Template(f.read())
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
class Mailgun(Email):
errors = {
400: "Mailgun failed with 400: Bad request",
401: "Mailgun failed with 401: Invalid API key",
}
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}')
elif response.status_code in Mailgun.errors:
log.error(f"{self.address}: {Mailgun.errors[response.status_code]}")
else:
log.error(
f"{self.address}: Mailgun failed with error {response.status_code}"
)
return response
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 += type(e).__name__
log.error(err)
log.error(
f"{self.address}: Failed to send via smtp ({type(e).__name__}: {e.args})"
)
try:
log.error(e.smtp_error)
except:

View 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

View File

@@ -0,0 +1,43 @@
from threading import Timer
import time
from jellyfin_accounts import config, data_store
from jellyfin_accounts.web_api import checkInvite
class Repeat:
def __init__(self, interval, function, *args, **kwargs):
self._timer = None
self.interval = interval
self.function = function
self.args = args
self.kwargs = kwargs
self.is_running = False
self.next_call = time.time()
self.start()
def _run(self):
self.is_running = False
self.start()
self.function(*self.args, **self.kwargs)
def start(self):
if not self.is_running:
self.next_call += self.interval
self._timer = Timer(self.next_call - time.time(), self._run)
self._timer.start()
self.is_running = True
def stop(self):
self._timer.cancel()
self.is_running = False
def checkInvites():
invites = dict(data_store.invites)
# checkInvite already loops over everything, no point running it multiple times.
if len(invites) != 0:
checkInvite(list(invites.keys())[0])
if config.getboolean("notifications", "enabled"):
inviteDaemon = Repeat(60, checkInvites)

View File

@@ -1,37 +1,50 @@
#!/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):
def __init__(self, server, client, version, device, deviceId, cacheMinutes=30):
"""
Initializes the Jellyfin object. All parameters except server
have no effect on the client's capability.
@@ -48,7 +61,7 @@ class Jellyfin:
self.version = version
self.device = device
self.deviceId = deviceId
self.timeout = 30 * 60
self.timeout = cacheMinutes * 60
self.userCacheAge = time.time() - self.timeout - 1
self.userCachePublicAge = self.userCacheAge
self.useragent = f"{self.client}/{self.version}"
@@ -58,40 +71,56 @@ 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 reloadCache(self):
""" Forces a reload of the user caches """
self.userCachePublicAge = time.time() - self.timeout - 1
self.getUsers()
try:
self.userCacheAge = self.userCachePublicAge
self.getUsers(public=False)
except self.AuthenticationRequiredError:
pass
def getUsers(self, username: str = "all", userId: str = "all", public: bool = True):
"""
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 +128,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 +148,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 +163,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:

View File

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

View File

@@ -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()
@@ -19,14 +19,15 @@ class Watcher:
self.observer.schedule(event_handler, self.dir, recursive=True)
try:
self.observer.start()
except NotADirectoryError:
log.error(f'Directory {self.dir} does not exist')
except (NotADirectoryError,
FileNotFoundError):
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 +35,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()

View File

@@ -1,50 +1,44 @@
# 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
import psutil
import sys
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 +46,31 @@ 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')
os._exit(1)
log.debug("Config written")
log.info('Restarting...')
try:
p = psutil.Process(os.getpid())
for handler in p.open_files() + p.connections():
os.close(handler.fd)
except:
pass
python = sys.executable
os.execl(python, python, *sys.argv)
return resp()
@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:

View File

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

View File

@@ -1,70 +1,96 @@
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"],
)

View File

@@ -1,69 +1,123 @@
# 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
import threading
import os
import sys
import psutil
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}
):
threading.Thread(target=email.send).start()
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 +126,382 @@ 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(),
}
):
threading.Thread(target=email.send).start()
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 and 'restart-program' not in section:
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.")
if 'restart-program' in data:
if data['restart-program']:
log.info('Restarting...')
try:
proc = psutil.Process(os.getpid())
for handler in proc.open_files() + proc.connections():
os.close(handler.fd)
except Exception as e:
log.error(f'Failed restart: {type(e).__name__}')
python = sys.executable
os.execl(python, python, *sys.argv)
return resp()
@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
View File

@@ -0,0 +1,8 @@
[Unit]
Description=A basic account management system for Jellyfin.
[Service]
ExecStart=/home/hrfee/.cache/pypoetry/virtualenvs/jellyfin-accounts-r2jcKHws-py3.8/bin/jf-accounts
[Install]
WantedBy=default.target

47
mail/created.mjml Normal file
View File

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

7
mail/created.txt Normal file
View File

@@ -0,0 +1,7 @@
A user was created using code {{ code }}.
Name: {{ username }}
Address: {{ address }}
Time: {{ time }}
Note: Notification emails can be toggled on the admin dashboard.

40
mail/email.mjml Normal file
View File

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

View File

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

36
mail/expired.mjml Normal file
View File

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

5
mail/expired.txt Normal file
View File

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

34
mail/generate.py Executable file
View 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
View File

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

2447
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

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

361
poetry.lock generated
View File

@@ -1,10 +1,52 @@
[[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."
name = "certifi"
optional = false
python-versions = "*"
version = "2020.4.5.2"
version = "2020.6.20"
[[package]]
category = "main"
@@ -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."
@@ -94,13 +124,21 @@ version = "4.1.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)"
name = "idna"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.9"
version = "2.10"
[[package]]
category = "main"
@@ -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"
@@ -154,6 +242,17 @@ optional = false
python-versions = "*"
version = "0.1.2"
[[package]]
category = "main"
description = "Cross-platform lib for process and system monitoring in Python."
name = "psutil"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "5.7.2"
[package.extras]
test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"]
[[package]]
category = "main"
description = "C parser in Python"
@@ -162,6 +261,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 +293,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,13 +320,21 @@ 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.7.14"
[[package]]
category = "main"
description = "Python HTTP for Humans."
name = "requests"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "2.23.0"
version = "2.24.0"
[package.dependencies]
certifi = ">=2017.4.17"
@@ -223,6 +354,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."
@@ -254,7 +412,7 @@ description = "Filesystem events monitoring"
name = "watchdog"
optional = false
python-versions = "*"
version = "0.10.2"
version = "0.10.3"
[package.dependencies]
pathtools = ">=0.1.1"
@@ -275,13 +433,25 @@ dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-
watchdog = ["watchdog"]
[metadata]
content-hash = "721be13a1e348d7e424529ba8466b9e2408df2cd97ab45e7e0d2f665b3213879"
content-hash = "1c2741c9be187d9d0be662509fb4a87f5978e5f44420e5049a20504824c29a59"
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"},
{file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"},
{file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"},
]
cffi = [
{file = "cffi-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384"},
@@ -321,10 +491,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"},
@@ -354,9 +520,28 @@ 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"},
]
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"},
{file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"},
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
]
itsdangerous = [
{file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"},
@@ -366,6 +551,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,21 +601,72 @@ 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"},
]
psutil = [
{file = "psutil-5.7.2-cp27-none-win32.whl", hash = "sha256:f2018461733b23f308c298653c8903d32aaad7873d25e1d228765e91ae42c3f2"},
{file = "psutil-5.7.2-cp27-none-win_amd64.whl", hash = "sha256:66c18ca7680a31bf16ee22b1d21b6397869dda8059dbdb57d9f27efa6615f195"},
{file = "psutil-5.7.2-cp35-cp35m-win32.whl", hash = "sha256:5e9d0f26d4194479a13d5f4b3798260c20cecf9ac9a461e718eb59ea520a360c"},
{file = "psutil-5.7.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4080869ed93cce662905b029a1770fe89c98787e543fa7347f075ade761b19d6"},
{file = "psutil-5.7.2-cp36-cp36m-win32.whl", hash = "sha256:d8a82162f23c53b8525cf5f14a355f5d1eea86fa8edde27287dd3a98399e4fdf"},
{file = "psutil-5.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:0ee3c36428f160d2d8fce3c583a0353e848abb7de9732c50cf3356dd49ad63f8"},
{file = "psutil-5.7.2-cp37-cp37m-win32.whl", hash = "sha256:ff1977ba1a5f71f89166d5145c3da1cea89a0fdb044075a12c720ee9123ec818"},
{file = "psutil-5.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a5b120bb3c0c71dfe27551f9da2f3209a8257a178ed6c628a819037a8df487f1"},
{file = "psutil-5.7.2-cp38-cp38-win32.whl", hash = "sha256:10512b46c95b02842c225f58fa00385c08fa00c68bac7da2d9a58ebe2c517498"},
{file = "psutil-5.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:68d36986ded5dac7c2dcd42f2682af1db80d4bce3faa126a6145c1637e1b559f"},
{file = "psutil-5.7.2.tar.gz", hash = "sha256:90990af1c3c67195c44c9a889184f84f5b2320dce3ee3acbd054e3ba0b4a7beb"},
]
pycparser = [
{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,14 +675,68 @@ 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.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"},
{file = "regex-2020.7.14-cp27-cp27m-win_amd64.whl", hash = "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88"},
{file = "regex-2020.7.14-cp36-cp36m-win32.whl", hash = "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4"},
{file = "regex-2020.7.14-cp36-cp36m-win_amd64.whl", hash = "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89"},
{file = "regex-2020.7.14-cp37-cp37m-win32.whl", hash = "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6"},
{file = "regex-2020.7.14-cp37-cp37m-win_amd64.whl", hash = "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204"},
{file = "regex-2020.7.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99"},
{file = "regex-2020.7.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e"},
{file = "regex-2020.7.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e"},
{file = "regex-2020.7.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a"},
{file = "regex-2020.7.14-cp38-cp38-win32.whl", hash = "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341"},
{file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"},
{file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"},
]
requests = [
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
{file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"},
{file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"},
]
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"},
@@ -441,7 +746,7 @@ waitress = [
{file = "waitress-1.4.4.tar.gz", hash = "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261"},
]
watchdog = [
{file = "watchdog-0.10.2.tar.gz", hash = "sha256:c560efb643faed5ef28784b2245cf8874f939569717a4a12826a173ac644456b"},
{file = "watchdog-0.10.3.tar.gz", hash = "sha256:4214e1379d128b0588021880ccaf40317ee156d4603ac388b9adcf29165e0c04"},
]
werkzeug = [
{file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},

View File

@@ -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,45 @@ 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"
psutil = "^5.7.2"
[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
View File

@@ -0,0 +1,147 @@
certifi==2020.4.5.2 \
--hash=sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc \
--hash=sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1
cffi==1.14.0 \
--hash=sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384 \
--hash=sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30 \
--hash=sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c \
--hash=sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78 \
--hash=sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793 \
--hash=sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e \
--hash=sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a \
--hash=sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff \
--hash=sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f \
--hash=sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa \
--hash=sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5 \
--hash=sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4 \
--hash=sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d \
--hash=sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc \
--hash=sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac \
--hash=sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f \
--hash=sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b \
--hash=sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3 \
--hash=sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66 \
--hash=sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0 \
--hash=sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f \
--hash=sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26 \
--hash=sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd \
--hash=sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55 \
--hash=sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2 \
--hash=sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8 \
--hash=sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b \
--hash=sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6
chardet==3.0.4 \
--hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \
--hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae
click==7.1.2 \
--hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc \
--hash=sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a
cryptography==2.9.2 \
--hash=sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e \
--hash=sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b \
--hash=sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365 \
--hash=sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0 \
--hash=sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55 \
--hash=sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270 \
--hash=sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf \
--hash=sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d \
--hash=sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785 \
--hash=sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b \
--hash=sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae \
--hash=sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b \
--hash=sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6 \
--hash=sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3 \
--hash=sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b \
--hash=sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e \
--hash=sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0 \
--hash=sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5 \
--hash=sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229
flask==1.1.2 \
--hash=sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557 \
--hash=sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060
flask-httpauth==3.3.0 \
--hash=sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9 \
--hash=sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72
idna==2.9 \
--hash=sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa \
--hash=sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb
itsdangerous==1.1.0 \
--hash=sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749 \
--hash=sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19
jinja2==2.11.2 \
--hash=sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035 \
--hash=sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0
markupsafe==1.1.1 \
--hash=sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161 \
--hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7 \
--hash=sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183 \
--hash=sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b \
--hash=sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e \
--hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \
--hash=sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1 \
--hash=sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5 \
--hash=sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1 \
--hash=sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735 \
--hash=sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21 \
--hash=sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235 \
--hash=sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b \
--hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \
--hash=sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905 \
--hash=sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1 \
--hash=sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d \
--hash=sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff \
--hash=sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473 \
--hash=sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e \
--hash=sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66 \
--hash=sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5 \
--hash=sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d \
--hash=sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e \
--hash=sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6 \
--hash=sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2 \
--hash=sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c \
--hash=sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15 \
--hash=sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2 \
--hash=sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42 \
--hash=sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b \
--hash=sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be \
--hash=sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b
packaging==20.4 \
--hash=sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181 \
--hash=sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8
passlib==1.7.2 \
--hash=sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177 \
--hash=sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8
pathtools==0.1.2 \
--hash=sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0
pycparser==2.20 \
--hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705 \
--hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0
pyopenssl==19.1.0 \
--hash=sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504 \
--hash=sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507
pyparsing==2.4.7 \
--hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b \
--hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1
python-dateutil==2.8.1 \
--hash=sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c \
--hash=sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a
pytz==2020.1 \
--hash=sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed \
--hash=sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048
requests==2.23.0 \
--hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \
--hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6
six==1.15.0 \
--hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced \
--hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259
urllib3==1.25.9 \
--hash=sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115 \
--hash=sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527
waitress==1.4.4 \
--hash=sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db \
--hash=sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261
watchdog==0.10.2 \
--hash=sha256:c560efb643faed5ef28784b2245cf8874f939569717a4a12826a173ac644456b
werkzeug==1.0.1 \
--hash=sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43 \
--hash=sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c

10
scss/README.md Normal file
View File

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

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

@@ -0,0 +1,140 @@
$jf-blue: rgb(0, 164, 220);
$jf-blue-hover: rgba(0, 164, 220, 0.2);
$jf-blue-focus: rgb(12, 176, 232);
$jf-blue-light: #4bb3dd;
$jf-red: rgb(204, 0, 0);
$jf-red-light: #e12026;
$jf-yellower: #ffc107;
$jf-yellow: #e1b222;
$jf-orange: #ff870f;
$jf-green: #6fbd45;
$jf-green-dark: #008040;
$jf-black: #101010; // 16 16 16
$jf-gray-90: #202020; // 32 32 32
$jf-gray-80: #242424; // jf-card 36 36 36
$jf-gray-70: #292929; // jf-input 41 41 41
$jf-gray-60: #303030; // jf-button 48 48 48
$jf-gray-50: #383838; // jf-button-focus 56 56 56
$jf-text-bold: rgba(255, 255, 255, 0.87);
$jf-text-primary: rgba(255, 255, 255, 0.8);
$jf-text-secondary: rgb(153, 153, 153);
$primary: $jf-blue;
$secondary: $jf-gray-50;
$success: $jf-green-dark;
$danger: $jf-red-light;
$light: $jf-text-primary;
$dark: $jf-gray-90;
$info: $jf-yellow;
$warning: $jf-yellower;
$enable-gradients: false;
$enable-shadows: false;
$enable-rounded: false;
$body-bg: $jf-black;
$body-color: $jf-text-primary;
$border-color: $jf-gray-60;
$component-active-color: $jf-text-bold;
$component-active-bg: $jf-blue-focus;
$text-muted: $jf-text-secondary;
$link-color: $jf-blue-focus;
$btn-link-disabled-color: $jf-text-secondary;
$input-bg: $jf-gray-90;
$input-color: $jf-text-primary;
$input-focus-bg: $jf-gray-60;
$input-focus-border-color: $jf-blue-focus;
$input-disabled-bg: $jf-gray-70;
input:disabled {
color: $text-muted;
}
$input-border-color: $jf-gray-60;
$input-placeholder-color: $text-muted;
$form-check-input-bg: $jf-gray-60;
$form-check-input-border: $jf-gray-50;
$form-check-input-checked-color: $jf-blue-focus;
$form-check-input-checked-bg-color: $jf-blue-hover;
$input-group-addon-bg: $input-bg;
$form-select-disabled-color: $jf-text-secondary;
$form-select-disabled-bg: $input-disabled-bg;
$form-select-indicator-color: $jf-gray-50;
$card-bg: $jf-gray-80;
$card-border-color: null;
$tooltip-color: $jf-text-bold;
$tooltip-bg: $jf-gray-50;
$modal-content-bg: $jf-gray-80;
$modal-content-border-color: $jf-gray-50;
$modal-header-border-color: null;
$modal-footer-border-color: null;
$list-group-bg: $card-bg;
$list-group-border-color: $jf-gray-50;
$list-group-hover-bg: $jf-blue-hover;
$list-group-active-bg: $jf-blue-focus;
$list-group-action-color: $jf-text-primary;
$list-group-action-hover-color: $jf-text-bold;
$list-group-action-active-color: $jf-text-bold;
$list-group-action-active-bg: $jf-blue-focus;
// idk why but i had to put these above and below the import
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}
@import "../../node_modules/bootstrap4/scss/bootstrap";
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
color: $jf-text-bold;
}
.close {
color: $jf-text-secondary;
}
.close:hover, .close:active {
color: $jf-text-primary;
}
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: $jf-text-bold;
}
.icon-button {
color: $text-muted;
}
.text-bright {
color: $jf-text-bold;
}
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}

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

@@ -0,0 +1,140 @@
$jf-blue: rgb(0, 164, 220);
$jf-blue-hover: rgba(0, 164, 220, 0.2);
$jf-blue-focus: rgb(12, 176, 232);
$jf-blue-light: #4bb3dd;
$jf-red: rgb(204, 0, 0);
$jf-red-light: #e12026;
$jf-yellower: #ffc107;
$jf-yellow: #e1b222;
$jf-orange: #ff870f;
$jf-green: #6fbd45;
$jf-green-dark: #008040;
$jf-black: #101010; // 16 16 16
$jf-gray-90: #202020; // 32 32 32
$jf-gray-80: #242424; // jf-card 36 36 36
$jf-gray-70: #292929; // jf-input 41 41 41
$jf-gray-60: #303030; // jf-button 48 48 48
$jf-gray-50: #383838; // jf-button-focus 56 56 56
$jf-text-bold: rgba(255, 255, 255, 0.87);
$jf-text-primary: rgba(255, 255, 255, 0.8);
$jf-text-secondary: rgb(153, 153, 153);
$primary: $jf-blue;
$secondary: $jf-gray-50;
$success: $jf-green-dark;
$danger: $jf-red-light;
$light: $jf-text-primary;
$dark: $jf-gray-90;
$info: $jf-yellow;
$warning: $jf-yellower;
$enable-gradients: false;
$enable-shadows: false;
$enable-rounded: false;
$body-bg: $jf-black;
$body-color: $jf-text-primary;
$border-color: $jf-gray-60;
$component-active-color: $jf-text-bold;
$component-active-bg: $jf-blue-focus;
$text-muted: $jf-text-secondary;
$link-color: $jf-blue-focus;
$btn-link-disabled-color: $jf-text-secondary;
$input-bg: $jf-gray-90;
$input-color: $jf-text-primary;
$input-focus-bg: $jf-gray-60;
$input-focus-border-color: $jf-blue-focus;
$input-disabled-bg: $jf-gray-70;
input:disabled {
color: $text-muted;
}
$input-border-color: $jf-gray-60;
$input-placeholder-color: $text-muted;
$form-check-input-bg: $jf-gray-60;
$form-check-input-border: $jf-gray-50;
$form-check-input-checked-color: $jf-blue-focus;
$form-check-input-checked-bg-color: $jf-blue-hover;
$input-group-addon-bg: $input-bg;
$form-select-disabled-color: $jf-text-secondary;
$form-select-disabled-bg: $input-disabled-bg;
$form-select-indicator-color: $jf-gray-50;
$card-bg: $jf-gray-80;
$card-border-color: null;
$tooltip-color: $jf-text-bold;
$tooltip-bg: $jf-gray-50;
$modal-content-bg: $jf-gray-80;
$modal-content-border-color: $jf-gray-50;
$modal-header-border-color: null;
$modal-footer-border-color: null;
$list-group-bg: $card-bg;
$list-group-border-color: $jf-gray-50;
$list-group-hover-bg: $jf-blue-hover;
$list-group-active-bg: $jf-blue-focus;
$list-group-action-color: $jf-text-primary;
$list-group-action-hover-color: $jf-text-bold;
$list-group-action-active-color: $jf-text-bold;
$list-group-action-active-bg: $jf-blue-focus;
// idk why but i had to put these above and below the import
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}
@import "../../node_modules/bootstrap/scss/bootstrap";
.btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active {
color: $jf-text-bold;
}
.close {
color: $jf-text-secondary;
}
.close:hover, .close:active {
color: $jf-text-primary;
}
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: $jf-text-bold;
}
.icon-button:active {
color: $text-muted;
}
.text-bright {
color: $jf-text-bold;
}
.list-group-item-danger {
color: $jf-text-bold;
background-color: $danger;
}
.list-group-item-success {
color: $jf-text-bold;
background-color: $success;
}

35
scss/compile.py Executable file
View File

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

17
scss/get_node_deps.py Normal file
View File

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