├── images ├── jfa.gif ├── admin.png ├── create.png ├── jellyfin-accounts-icon.png ├── jellyfin-accounts-social.png ├── jellyfin-accounts-icon.afdesign ├── jellyfin-accounts-banner.afdesign ├── jellyfin-accounts-banner-wide.afdesign └── README.md ├── jellyfin_accounts ├── data │ ├── static │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── browserconfig.xml │ │ ├── site.webmanifest │ │ ├── safari-pinned-tab.svg │ │ ├── serialize.js │ │ └── setup.js │ ├── services │ │ └── jf-accounts.service │ ├── templates │ │ ├── invalidCode.html │ │ ├── 404.html │ │ ├── form.html │ │ └── admin.html │ └── config-base.json ├── generate_ini.py ├── invite_daemon.py ├── validate_password.py ├── data_store.py ├── pw_reset.py ├── setup.py ├── web.py ├── login.py ├── config.py ├── email.py ├── jf_api.py ├── __init__.py └── web_api.py ├── mail ├── expired.txt ├── created.txt ├── invite-email.txt ├── email.txt ├── expired.mjml ├── invite-email.mjml ├── email.mjml ├── created.mjml └── generate.py ├── jf-accounts.service ├── README.md ├── .gitignore ├── scss ├── get_node_deps.py ├── README.md ├── compile.py ├── bs4 │ └── bs4-jf.scss └── bs5 │ └── bs5-jf.scss ├── package.json ├── Dockerfile ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── LICENSE ├── pyproject.toml ├── README.old.md ├── config-default.ini └── requirements.txt /images/jfa.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/images/jfa.gif -------------------------------------------------------------------------------- /images/admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/images/admin.png -------------------------------------------------------------------------------- /images/create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/images/create.png -------------------------------------------------------------------------------- /images/jellyfin-accounts-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/images/jellyfin-accounts-icon.png -------------------------------------------------------------------------------- /images/jellyfin-accounts-social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/images/jellyfin-accounts-social.png -------------------------------------------------------------------------------- /images/jellyfin-accounts-icon.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/images/jellyfin-accounts-icon.afdesign -------------------------------------------------------------------------------- /images/jellyfin-accounts-banner.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/images/jellyfin-accounts-banner.afdesign -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/jellyfin_accounts/data/static/favicon.ico -------------------------------------------------------------------------------- /images/jellyfin-accounts-banner-wide.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/images/jellyfin-accounts-banner-wide.afdesign -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/jellyfin_accounts/data/static/favicon-16x16.png -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/jellyfin_accounts/data/static/favicon-32x32.png -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/jellyfin_accounts/data/static/mstile-150x150.png -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/jellyfin_accounts/data/static/apple-touch-icon.png -------------------------------------------------------------------------------- /mail/expired.txt: -------------------------------------------------------------------------------- 1 | Invite expired. 2 | 3 | Code {{ code }} expired at {{ expiry }}. 4 | 5 | Note: Notification emails can be toggled on the admin dashboard. 6 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/jellyfin_accounts/data/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hrfee/jellyfin-accounts/HEAD/jellyfin_accounts/data/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /mail/created.txt: -------------------------------------------------------------------------------- 1 | A user was created using code {{ code }}. 2 | 3 | Name: {{ username }} 4 | Address: {{ address }} 5 | Time: {{ time }} 6 | 7 | Note: Notification emails can be toggled on the admin dashboard. 8 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/services/jf-accounts.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=A basic account management system for Jellyfin. 3 | 4 | [Service] 5 | ExecStart={executable} 6 | 7 | [Install] 8 | WantedBy=default.target 9 | -------------------------------------------------------------------------------- /images/README.md: -------------------------------------------------------------------------------- 1 | # Images 2 | 3 | This holds any images on the main README, and the base files for the icons and banner. The font used, like Jellyfin, is [Quicksand](https://fonts.google.com/specimen/Quicksand) by Andrew Paglinawan. 4 | -------------------------------------------------------------------------------- /jf-accounts.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=A basic account management system for Jellyfin. 3 | 4 | [Service] 5 | ExecStart=/home/hrfee/.cache/pypoetry/virtualenvs/jellyfin-accounts-r2jcKHws-py3.8/bin/jf-accounts 6 | 7 | [Install] 8 | WantedBy=default.target 9 | -------------------------------------------------------------------------------- /mail/invite-email.txt: -------------------------------------------------------------------------------- 1 | Hi, 2 | You've been invited to Jellyfin. 3 | To join, follow the below link. 4 | This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick. 5 | 6 | {{ invite_link }} 7 | 8 | {{ message }} 9 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #603cba 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 👀 ➡️: Try [jfa-go](https://github.com/hrfee/jfa-go), a rewrite in Go. It's faster and has more features. 2 | ###### I won't be updating this version any more, and switching should be easy. 3 | 4 | **please don't open any new issues.** 5 | 6 | You can find the old README [here](https://github.com/hrfee/jellyfin-accounts/blob/main/README.old.md). 7 | -------------------------------------------------------------------------------- /mail/email.txt: -------------------------------------------------------------------------------- 1 | Hi {{ username }}, 2 | 3 | Someone has recently requests a password reset on Jellyfin. 4 | If this was you, enter the below pin into the prompt. 5 | This code will expire on {{ expiry_date }}, at {{ expiry_time }} UTC, which is in {{ expires_in }}. 6 | If this wasn't you, please ignore this email. 7 | 8 | PIN: {{ pin }} 9 | 10 | {{ message }} 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | notes.md 3 | MANIFEST.in 4 | dist/ 5 | build/ 6 | test.txt 7 | node_modules/ 8 | jellyfin_accounts/data/config-default.ini 9 | *.egg-info/ 10 | pw-reset/ 11 | jfa/ 12 | colors.txt 13 | theme.css 14 | jellyfin_accounts/__pycache__/ 15 | jellyfin_accounts/data/static/*.css 16 | old/ 17 | .jf-accounts/ 18 | requirements.txt 19 | video/ 20 | scss/bs5/*.css* 21 | scss/bs4/*.css* 22 | mail/*.html 23 | jellyfin_accounts/data/*.html 24 | jellyfin_accounts/data/*.txt 25 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jf-accounts", 3 | "short_name": "jf-accounts", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /scss/get_node_deps.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import subprocess 4 | import os 5 | from pathlib import Path 6 | 7 | 8 | def runcmd(cmd): 9 | if os.name == "nt": 10 | return subprocess.check_output(cmd, shell=True) 11 | proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) 12 | return proc.communicate() 13 | 14 | 15 | print('Installing npm packages') 16 | 17 | root_path = Path(__file__).parents[1] 18 | if os.name == 'nt': 19 | root_path /= 'node_modules' 20 | runcmd(f'npm install') 21 | 22 | if (root_path / 'node_modules' / 'cleancss').exists(): 23 | print(f'Installed successfully in {str((root_path / "node_modules").resolve())}.') 24 | 25 | 26 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/serialize.js: -------------------------------------------------------------------------------- 1 | function serializeForm(id) { 2 | var form = document.getElementById(id); 3 | var formData = {}; 4 | for (var i = 0; i < form.elements.length; i++) { 5 | var el = form.elements[i]; 6 | if (el.type != 'submit') { 7 | var name = el.name; 8 | if (name == '') { 9 | name = el.id; 10 | }; 11 | switch (el.type) { 12 | case 'checkbox': 13 | formData[name] = el.checked; 14 | break; 15 | case 'text': 16 | case 'password': 17 | case 'select-one': 18 | case 'email': 19 | case 'number': 20 | formData[name] = el.value; 21 | break; 22 | }; 23 | }; 24 | }; 25 | return formData; 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jellyfin-accounts", 3 | "version": "1.0.0", 4 | "description": "This is only used for grabbing scss build dependencies, and isn't a real package.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/hrfee/jellyfin-accounts.git" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/hrfee/jellyfin-accounts/issues" 17 | }, 18 | "homepage": "https://github.com/hrfee/jellyfin-accounts#readme", 19 | "dependencies": { 20 | "autoprefixer": "^9.8.5", 21 | "bootstrap": "^5.0.0-alpha1", 22 | "bootstrap4": "npm:bootstrap@^4.5.0", 23 | "clean-css-cli": "^4.3.0", 24 | "lodash": "^4.17.19", 25 | "mjml": "^4.6.3", 26 | "postcss-cli": "^7.1.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scss/README.md: -------------------------------------------------------------------------------- 1 | ## SCSS 2 | 3 | * `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. 4 | 5 | **Note**: It is assumed that Bootstrap 5 is installed in `../../node_modules/bootstrap` relative to itself, and Bootstrap 4 in `../../node_modules/bootstrap4`. 6 | 7 | * Compilation requires dev dependencies (`poetry update`), bootstrap and some extra npm packages. 8 | * If you're buildings from source, you can simply run `poetry run task compile-css` before building to automatically get deps and compile CSS. 9 | * 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`. 10 | 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.2-buster AS build 2 | 3 | COPY . /opt/build 4 | 5 | RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python 6 | 7 | RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - 8 | 9 | RUN cd /opt/build \ 10 | && rm -rf dist \ 11 | && apt install nodejs \ 12 | && ~/.poetry/bin/poetry update \ 13 | && pip install libsass \ 14 | && python scss/get_node_deps.py \ 15 | && python scss/compile.py -y \ 16 | && python mail/generate.py -y \ 17 | && ~/.poetry/bin/poetry build -f wheel 18 | 19 | FROM python:3.8.2-buster 20 | 21 | COPY --from=build /opt/build/dist /opt/dist 22 | 23 | RUN pip install /opt/dist/*.whl 24 | 25 | RUN sed -i 's#id="pwrJfPath" placeholder="Folder"#id="pwrJfPath" value="/jf" disabled#g' /usr/local/lib/python3.8/site-packages/jellyfin_accounts/data/templates/setup.html 26 | 27 | CMD [ "python3.8", "/usr/local/bin/jf-accounts", "-d", "/data" ] 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Template for bug reports. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | Describe the problem, and what you would expect if it isn't clear already. 13 | 14 | **To Reproduce** 15 | 16 | What to do to reproduce the problem. 17 | 18 | **Logs** 19 | 20 | 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`\`\`\`). 21 | 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. 22 | 23 | **Platform** 24 | 25 | Include the platform jf-accounts is running on (e.g Windows, Linux, Docker), the python version, and if necessary the browser version and platform. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Harvey Tindall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /jellyfin_accounts/generate_ini.py: -------------------------------------------------------------------------------- 1 | # Generates config file 2 | import configparser 3 | import json 4 | from pathlib import Path 5 | 6 | 7 | def generate_ini(base_file, ini_file, version): 8 | """ 9 | Generates .ini file from config-base file. 10 | """ 11 | with open(Path(base_file), "r") as f: 12 | config_base = json.load(f) 13 | 14 | ini = configparser.RawConfigParser(allow_no_value=True) 15 | 16 | for section in config_base: 17 | ini.add_section(section) 18 | for entry in config_base[section]: 19 | if "description" in config_base[section][entry]: 20 | ini.set(section, "; " + config_base[section][entry]["description"]) 21 | if entry != "meta": 22 | value = config_base[section][entry]["value"] 23 | if isinstance(value, bool): 24 | value = str(value).lower() 25 | else: 26 | value = str(value) 27 | ini.set(section, entry, value) 28 | 29 | ini["jellyfin"]["version"] = version 30 | ini["jellyfin"]["device_id"] = ini["jellyfin"]["device_id"].replace( 31 | "{version}", version 32 | ) 33 | 34 | with open(Path(ini_file), "w") as config_file: 35 | ini.write(config_file) 36 | return True 37 | -------------------------------------------------------------------------------- /jellyfin_accounts/invite_daemon.py: -------------------------------------------------------------------------------- 1 | from threading import Timer 2 | import time 3 | from jellyfin_accounts import config, data_store 4 | from jellyfin_accounts.web_api import checkInvite 5 | 6 | 7 | class Repeat: 8 | def __init__(self, interval, function, *args, **kwargs): 9 | self._timer = None 10 | self.interval = interval 11 | self.function = function 12 | self.args = args 13 | self.kwargs = kwargs 14 | self.is_running = False 15 | self.next_call = time.time() 16 | self.start() 17 | 18 | def _run(self): 19 | self.is_running = False 20 | self.start() 21 | self.function(*self.args, **self.kwargs) 22 | 23 | def start(self): 24 | if not self.is_running: 25 | self.next_call += self.interval 26 | self._timer = Timer(self.next_call - time.time(), self._run) 27 | self._timer.start() 28 | self.is_running = True 29 | 30 | def stop(self): 31 | self._timer.cancel() 32 | self.is_running = False 33 | 34 | 35 | def checkInvites(): 36 | invites = dict(data_store.invites) 37 | # checkInvite already loops over everything, no point running it multiple times. 38 | if len(invites) != 0: 39 | checkInvite(list(invites.keys())[0]) 40 | 41 | 42 | if config.getboolean("notifications", "enabled"): 43 | inviteDaemon = Repeat(60, checkInvites) 44 | -------------------------------------------------------------------------------- /mail/expired.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | jellyfin-accounts 18 | 19 | 20 | 21 | 22 | 23 |

Invite Expired.

24 |

Code {{ code }} expired at {{ expiry }}.

25 |
26 |
27 |
28 | 29 | 30 | 31 | Notification emails can be toggled on the admin dashboard. 32 | 33 | 34 | 35 | 36 |
37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "jellyfin-accounts" 3 | version = "0.3.9" 4 | readme = "README.md" 5 | description = "A simple account management system for Jellyfin" 6 | authors = ["Harvey Tindall "] 7 | license = "MIT" 8 | homepage = "https://github.com/hrfee/jellyfin-accounts" 9 | repository = "https://github.com/hrfee/jellyfin-accounts" 10 | keywords = ["jellyfin", "jf-accounts"] 11 | include = ["jellyfin_accounts/data/*", "jellyfin_accounts/data/static/*.css", "jellyfin_accounts/data/*.html"] 12 | exclude = ["images/*", "scss/*", "mail/*"] 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | ] 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.6" 21 | pyopenssl = "^19.1.0" 22 | flask = "^1.1.2" 23 | flask-httpauth = ">= 3.3.0" 24 | requests = "^2.23.0" 25 | itsdangerous = "^1.1.0" 26 | passlib = "^1.7.2" 27 | pytz = "^2020.1" 28 | python-dateutil = "^2.8.1" 29 | watchdog = "^0.10.2" 30 | waitress = "^1.4.3" 31 | packaging = "^20.4" 32 | psutil = "^5.7.2" 33 | 34 | [tool.poetry.dev-dependencies] 35 | neovim = "^0.3.1" 36 | black = "^19.10b0" 37 | taskipy = "^1.2.1" 38 | libsass = "^0.20.0" 39 | 40 | [tool.poetry.scripts] 41 | jf-accounts = 'jellyfin_accounts:main' 42 | 43 | [tool.taskipy.tasks] 44 | pre_compile-css = "task get-npm-deps" 45 | compile-css = "python scss/compile.py" 46 | get-npm-deps = "python scss/get_node_deps.py" 47 | pre_generate-emails = "task get-npm-deps" 48 | generate-emails = "python mail/generate.py" 49 | 50 | [build-system] 51 | requires = ["poetry>=0.12"] 52 | build-backend = "poetry.masonry.api" 53 | -------------------------------------------------------------------------------- /mail/invite-email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Jellyfin 18 | 19 | 20 | 21 | 22 | 23 |

Hi,

24 |

You've been invited to Jellyfin.

25 |

To join, click the button below.

26 |

This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.

27 |
28 | Setup your account 29 |
30 |
31 | 32 | 33 | 34 | {{ message }} 35 | 36 | 37 | 38 | 39 |
-------------------------------------------------------------------------------- /mail/email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Jellyfin 18 | 19 | 20 | 21 | 22 | 23 |

Hi {{ username }},

24 |

Someone has recently requested a password reset on Jellyfin.

25 |

If this was you, enter the below pin into the prompt.

26 |

The code will expire on {{ expiry_date }}, at {{ expiry_time }} UTC, which is in {{ expires_in }}.

27 |

If this wasn't you, please ignore this email.

28 |
29 | {{ pin }} 30 |
31 |
32 | 33 | 34 | 35 | {{ message }} 36 | 37 | 38 | 39 | 40 |
41 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/templates/invalidCode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Invalid Code 7 | 8 | 9 | {% if not bs5 %} 10 | 11 | {% endif %} 12 | 13 | {% if bs5 %} 14 | 15 | {% else %} 16 | 17 | {% endif %} 18 | 19 | 24 | 25 | 26 |
27 |

Invalid Code.

28 |

The above code is either incorrect, or has expired.

29 |

{{ contactMessage }}

30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /jellyfin_accounts/validate_password.py: -------------------------------------------------------------------------------- 1 | # Password validation 2 | specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')', 3 | '<', '>', '?', '/', '\\', '|', '}', '{', '~', ':', ']'] 4 | 5 | 6 | class PasswordValidator: 7 | def __init__(self, min_length, upper, lower, number, special): 8 | self.criteria = { 9 | "characters": int(min_length), 10 | "uppercase characters": int(upper), 11 | "lowercase characters": int(lower), 12 | "numbers": int(number), 13 | "special characters": int(special), 14 | } 15 | 16 | def validate(self, password): 17 | count = { 18 | "characters": 0, 19 | "uppercase characters": 0, 20 | "lowercase characters": 0, 21 | "numbers": 0, 22 | "special characters": 0, 23 | } 24 | for c in password: 25 | count["characters"] += 1 26 | if c.isupper(): 27 | count["uppercase characters"] += 1 28 | elif c.islower(): 29 | count["lowercase characters"] += 1 30 | elif c.isnumeric(): 31 | count["numbers"] += 1 32 | elif c in specials: 33 | count["special characters"] += 1 34 | for criterion in count: 35 | if count[criterion] < self.criteria[criterion]: 36 | count[criterion] = False 37 | else: 38 | count[criterion] = True 39 | return count 40 | 41 | def getCriteria(self): 42 | lines = {} 43 | for criterion in self.criteria: 44 | min = self.criteria[criterion] 45 | if min > 0: 46 | text = f"Must have at least {min} " 47 | if min == 1: 48 | text += criterion[:-1] 49 | else: 50 | text += criterion 51 | lines[criterion] = text 52 | return lines 53 | -------------------------------------------------------------------------------- /mail/created.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | jellyfin-accounts 18 | 19 | 20 | 21 | 22 | 23 |

User Created

24 |

A user was created using code {{ code }}.

25 |
26 | 27 | 28 | Name 29 | Address 30 | Time 31 | 32 | 33 | {{ username }} 34 | {{ address }} 35 | {{ time }} 36 | 37 |
38 |
39 | 40 | 41 | 42 | Notification emails can be toggled on the admin dashboard. 43 | 44 | 45 | 46 | 47 |
-------------------------------------------------------------------------------- /mail/generate.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import shutil 3 | import os 4 | import argparse 5 | from pathlib import Path 6 | 7 | parser = argparse.ArgumentParser() 8 | 9 | parser.add_argument( 10 | "-y", "--yes", help="use assumed node bin directory.", action="store_true" 11 | ) 12 | 13 | 14 | def runcmd(cmd): 15 | if os.name == "nt": 16 | return subprocess.check_output(cmd, shell=True) 17 | proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) 18 | return proc.communicate() 19 | 20 | local_path = Path(__file__).resolve().parent 21 | out = runcmd("npm bin") 22 | 23 | try: 24 | node_bin = Path(out[0].decode('utf-8').rstrip()) 25 | except: 26 | node_bin = Path(out.decode('utf-8').rstrip()) 27 | 28 | args = parser.parse_args() 29 | 30 | if not args.yes: 31 | print(f"assuming npm bin directory \"{node_bin}\". Is this correct?") 32 | if input("[yY/nN]: ").lower() == "n": 33 | node_bin = local_path.parent / 'node_modules' / '.bin' 34 | print(f"this? \"{node_bin}\"") 35 | if input("[yY/nN]: ").lower() == "n": 36 | node_bin = input("input bin directory: ") 37 | 38 | for mjml in [f for f in local_path.iterdir() if f.is_file() and 'mjml' in f.suffix]: 39 | print(f'Compiling {mjml.name}') 40 | fname = mjml.with_suffix(".html") 41 | runcmd(f'{str(node_bin / "mjml")} {str(mjml)} -o {str(fname)}') 42 | if fname.is_file(): 43 | print('Done.') 44 | 45 | html = [f for f in local_path.iterdir() if f.is_file() and 'html' in f.suffix] 46 | 47 | output = local_path.parent / 'jellyfin_accounts' / 'data' 48 | 49 | for f in html: 50 | shutil.copy(str(f), 51 | str(output / f.name)) 52 | print(f'Copied {f.name} to {str(output / f.name)}') 53 | txtfile = f.with_suffix('.txt') 54 | if txtfile.is_file(): 55 | shutil.copy(str(txtfile), 56 | str(output / txtfile.name)) 57 | print(f'Copied {txtfile.name} to {str(output / txtfile.name)}') 58 | else: 59 | print(f'Warning: {txtfile.name} does not exist. Text versions of emails should be supplied.') 60 | 61 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 404 16 | 17 | {% if not bs5 %} 18 | 19 | {% endif %} 20 | 21 | {% if bs5 %} 22 | 23 | {% else %} 24 | 25 | {% endif %} 26 | 27 | 32 | 33 | 34 |
35 |

Page not found.

36 |

37 | {{ contactMessage }} 38 |

39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /jellyfin_accounts/data_store.py: -------------------------------------------------------------------------------- 1 | # Automatic storage of everything except the config 2 | import json 3 | import datetime 4 | 5 | 6 | class JSONFile(dict): 7 | """ 8 | Behaves like a dictionary, but automatically 9 | reads and writes to a JSON file (most of the time). 10 | """ 11 | 12 | @staticmethod 13 | def readJSON(path): 14 | try: 15 | with open(path, "r") as f: 16 | return json.load(f) 17 | except FileNotFoundError: 18 | return {} 19 | 20 | @staticmethod 21 | def writeJSON(path, data): 22 | with open(path, "w") as f: 23 | return f.write(json.dumps(data, indent=4, default=str)) 24 | 25 | def __init__(self, path, data=None): 26 | self.path = path 27 | if data is None: 28 | super(JSONFile, self).__init__(self.readJSON(self.path)) 29 | else: 30 | super(JSONFile, self).__init__(data) 31 | self.writeJSON(self.path, data) 32 | 33 | def __getitem__(self, key): 34 | super(JSONFile, self).__init__(self.readJSON(self.path)) 35 | return super(JSONFile, self).__getitem__(key) 36 | 37 | def __setitem__(self, key, value): 38 | data = self.readJSON(self.path) 39 | data[key] = value 40 | self.writeJSON(self.path, data) 41 | super(JSONFile, self).__init__(data) 42 | 43 | def __delitem__(self, key): 44 | data = self.readJSON(self.path) 45 | super(JSONFile, self).__init__(data) 46 | try: 47 | del data[key] 48 | except KeyError: 49 | pass 50 | self.writeJSON(self.path, data) 51 | super(JSONFile, self).__delitem__(key) 52 | 53 | def __str__(self): 54 | super(JSONFile, self).__init__(self.readJSON(self.path)) 55 | return json.dumps(super(JSONFile, self)) 56 | 57 | 58 | class JSONStorage: 59 | def __init__( 60 | self, emails, invites, user_template, user_displayprefs, user_configuration 61 | ): 62 | self.emails = JSONFile(path=emails) 63 | self.invites = JSONFile(path=invites) 64 | self.user_template = JSONFile(path=user_template) 65 | self.user_displayprefs = JSONFile(path=user_displayprefs) 66 | self.user_configuration = JSONFile(path=user_configuration) 67 | 68 | def __setattr__(self, name, value): 69 | if hasattr(self, name): 70 | path = self.__dict__[name].path 71 | self.__dict__[name] = JSONFile(path=path, data=value) 72 | else: 73 | self.__dict__[name] = value 74 | -------------------------------------------------------------------------------- /scss/compile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sass 3 | import subprocess 4 | import shutil 5 | import os 6 | import argparse 7 | from pathlib import Path 8 | 9 | parser = argparse.ArgumentParser() 10 | 11 | parser.add_argument( 12 | "-y", "--yes", help="use assumed node bin directory.", action="store_true" 13 | ) 14 | 15 | 16 | def runcmd(cmd): 17 | if os.name == "nt": 18 | return subprocess.check_output(cmd, shell=True) 19 | proc = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) 20 | return proc.communicate() 21 | 22 | local_path = Path(__file__).resolve().parent 23 | out = runcmd("npm bin") 24 | 25 | try: 26 | node_bin = Path(out[0].decode('utf-8').rstrip()) 27 | except: 28 | node_bin = Path(out.decode('utf-8').rstrip()) 29 | 30 | args = parser.parse_args() 31 | 32 | if not args.yes: 33 | print(f"assuming npm bin directory \"{node_bin}\". Is this correct?") 34 | if input("[yY/nN]: ").lower() == "n": 35 | node_bin = local_path.parent / 'node_modules' / '.bin' 36 | print(f"this? \"{node_bin}\"") 37 | if input("[yY/nN]: ").lower() == "n": 38 | node_bin = input("input bin directory: ") 39 | 40 | for bsv in [d for d in local_path.iterdir() if 'bs' in d.name]: 41 | scss = bsv / f'{bsv.name}-jf.scss' 42 | css = bsv / f'{bsv.name}-jf.css' 43 | min_css = bsv.parents[1] / 'jellyfin_accounts' / 'data' / 'static' / f'{bsv.name}-jf.css' 44 | with open(css, 'w') as f: 45 | f.write(sass.compile(filename=str(scss.resolve()), 46 | output_style='expanded', 47 | precision=6)) 48 | if css.exists(): 49 | print(f'{bsv.name}: Compiled.') 50 | # postcss only excepts forwards slashes? weird. 51 | cssPath = str(css.resolve()) 52 | if os.name == 'nt': 53 | cssPath = cssPath.replace('\\', '/') 54 | runcmd(f'{str((node_bin / "postcss").resolve())} {cssPath} --replace --use autoprefixer') 55 | print(f'{bsv.name}: Prefixed.') 56 | runcmd(f'{str((node_bin / "cleancss").resolve())} --level 1 --format breakWith=lf --output {str(min_css.resolve())} {str(css.resolve())}') 57 | if min_css.exists(): 58 | print(f'{bsv.name}: Minified and copied to {str(min_css.resolve())}.') 59 | 60 | for v in [('bootstrap', 'bs5'), ('bootstrap4', 'bs4')]: 61 | new_path = str((local_path.parent / 'jellyfin_accounts' / 'data' / 'static' / (v[1] + '.css')).resolve()) 62 | shutil.copy(str((local_path.parent / 'node_modules' / v[0] / 'dist' / 'css' / 'bootstrap.min.css').resolve()), 63 | new_path) 64 | print(f'Copied {v[1]} to {new_path}') 65 | 66 | -------------------------------------------------------------------------------- /jellyfin_accounts/pw_reset.py: -------------------------------------------------------------------------------- 1 | # Watches Jellyfin for password resets and sends emails. 2 | import time 3 | import json 4 | from watchdog.observers import Observer 5 | from watchdog.events import FileSystemEventHandler 6 | from jellyfin_accounts.email import Mailgun, Smtp 7 | from jellyfin_accounts.web_api import jf 8 | from jellyfin_accounts import config, data_store 9 | from jellyfin_accounts import pwr_log as log 10 | 11 | 12 | class Watcher: 13 | def __init__(self, dir): 14 | self.observer = Observer() 15 | self.dir = str(dir) 16 | 17 | def run(self): 18 | event_handler = Handler() 19 | self.observer.schedule(event_handler, self.dir, recursive=True) 20 | try: 21 | self.observer.start() 22 | except (NotADirectoryError, 23 | FileNotFoundError): 24 | log.error(f"Directory {self.dir} does not exist") 25 | try: 26 | while True: 27 | time.sleep(5) 28 | except: 29 | self.observer.stop() 30 | log.info("Watchdog stopped") 31 | 32 | 33 | class Handler(FileSystemEventHandler): 34 | @staticmethod 35 | def on_any_event(event): 36 | if event.is_directory: 37 | return None 38 | elif event.event_type == "modified" and "passwordreset" in event.src_path: 39 | log.debug(f"Password reset file: {event.src_path}") 40 | time.sleep(1) 41 | with open(event.src_path, "r") as f: 42 | reset = json.load(f) 43 | log.info(f'New password reset for {reset["UserName"]}') 44 | try: 45 | id = jf.getUsers(reset["UserName"], public=False)["Id"] 46 | address = data_store.emails[id] 47 | if address != "": 48 | method = config["email"]["method"] 49 | if method == "mailgun": 50 | email = Mailgun(address) 51 | elif method == "smtp": 52 | email = Smtp(address) 53 | if email.construct_reset(reset): 54 | email.send() 55 | else: 56 | raise IndexError 57 | except ( 58 | FileNotFoundError, 59 | json.decoder.JSONDecodeError, 60 | IndexError, 61 | ) as e: 62 | err = f"{address}: Failed: " + type(e).__name__ 63 | log.error(err) 64 | 65 | 66 | def start(): 67 | log.info(f'Monitoring {config["password_resets"]["watch_directory"]}') 68 | w = Watcher(config["password_resets"]["watch_directory"]) 69 | w.run() 70 | -------------------------------------------------------------------------------- /jellyfin_accounts/setup.py: -------------------------------------------------------------------------------- 1 | # Views and endpoints for the initial setup 2 | from flask import request, jsonify, render_template 3 | from configparser import RawConfigParser 4 | from jellyfin_accounts.jf_api import Jellyfin 5 | from jellyfin_accounts import config, config_path, app, first_run, resp 6 | from jellyfin_accounts import web_log as log 7 | import os 8 | import psutil 9 | import sys 10 | 11 | if first_run: 12 | 13 | def tempJF(server): 14 | return Jellyfin( 15 | server, 16 | config["jellyfin"]["client"], 17 | config["jellyfin"]["version"], 18 | config["jellyfin"]["device"] + "_temp", 19 | config["jellyfin"]["device_id"] + "_temp", 20 | ) 21 | 22 | @app.errorhandler(404) 23 | def page_not_found(e): 24 | return render_template("404.html"), 404 25 | 26 | @app.route("/", methods=["GET", "POST"]) 27 | def setup(): 28 | return render_template("setup.html") 29 | 30 | @app.route("/") 31 | def static_proxy(path): 32 | if "html" not in path: 33 | return app.send_static_file(path) 34 | else: 35 | return render_template("404.html"), 404 36 | 37 | @app.route("/modifyConfig", methods=["POST"]) 38 | def modifyConfig(): 39 | log.info("Config modification requested") 40 | data = request.get_json() 41 | temp_config = RawConfigParser(comment_prefixes="/", allow_no_value=True) 42 | temp_config.read(config_path) 43 | for section in data: 44 | if section in temp_config: 45 | for item in data[section]: 46 | if item in temp_config[section]: 47 | temp_config[section][item] = data[section][item] 48 | data[section][item] = True 49 | log.debug(f"{section}/{item} modified") 50 | else: 51 | data[section][item] = False 52 | log.debug(f"{section}/{item} does not exist in config") 53 | with open(config_path, "w") as config_file: 54 | temp_config.write(config_file) 55 | log.debug("Config written") 56 | log.info('Restarting...') 57 | try: 58 | p = psutil.Process(os.getpid()) 59 | for handler in p.open_files() + p.connections(): 60 | os.close(handler.fd) 61 | except: 62 | pass 63 | 64 | python = sys.executable 65 | os.execl(python, python, *sys.argv) 66 | return resp() 67 | 68 | @app.route("/testJF", methods=["GET", "POST"]) 69 | def testJF(): 70 | data = request.get_json() 71 | tempjf = tempJF(data["jfHost"]) 72 | try: 73 | tempjf.authenticate(data["jfUser"], data["jfPassword"]) 74 | tempjf.getUsers(public=False) 75 | return resp() 76 | except: 77 | return resp(False) 78 | -------------------------------------------------------------------------------- /jellyfin_accounts/web.py: -------------------------------------------------------------------------------- 1 | # Web views 2 | from pathlib import Path 3 | from flask import Flask, send_from_directory, render_template 4 | 5 | from jellyfin_accounts import config, app, g, css_file, data_store 6 | from jellyfin_accounts import web_log as log 7 | from jellyfin_accounts.web_api import checkInvite, validator 8 | 9 | 10 | def bsVersion(): 11 | if config.getboolean("ui", "bs5"): 12 | return 5 13 | return 4 14 | 15 | 16 | @app.errorhandler(404) 17 | def page_not_found(e): 18 | return ( 19 | render_template( 20 | "404.html", 21 | bs5=config.getboolean("ui", "bs5"), 22 | css_file=css_file, 23 | contactMessage=config["ui"]["contact_message"], 24 | ), 25 | 404, 26 | ) 27 | 28 | 29 | @app.route("/", methods=["GET", "POST"]) 30 | def admin(): 31 | return render_template( 32 | "admin.html", 33 | bs5=config.getboolean("ui", "bs5"), 34 | css_file=css_file, 35 | contactMessage="", 36 | email_enabled=config.getboolean("invite_emails", "enabled"), 37 | ) 38 | 39 | 40 | @app.route("/") 41 | def static_proxy(path): 42 | if "html" not in path: 43 | if "admin.js" in path: 44 | return ( 45 | render_template( 46 | "admin.js", 47 | bsVersion=bsVersion(), 48 | css_file=css_file, 49 | notifications=config.getboolean("notifications", "enabled"), 50 | ), 51 | 200, 52 | {"Content-Type": "text/javascript"}, 53 | ) 54 | return app.send_static_file(path) 55 | return ( 56 | render_template( 57 | "404.html", 58 | bs5=config.getboolean("ui", "bs5"), 59 | css_file=css_file, 60 | contactMessage=config["ui"]["contact_message"], 61 | ), 62 | 404, 63 | ) 64 | 65 | 66 | @app.route("/invite/") 67 | def inviteProxy(path): 68 | if checkInvite(path): 69 | log.info(f"Invite {path} used to request form") 70 | try: 71 | email = data_store.invites[path]["email"] 72 | except KeyError: 73 | email = "" 74 | return render_template( 75 | "form.html", 76 | bs5=config.getboolean("ui", "bs5"), 77 | css_file=css_file, 78 | contactMessage=config["ui"]["contact_message"], 79 | helpMessage=config["ui"]["help_message"], 80 | successMessage=config["ui"]["success_message"], 81 | jfLink=config["jellyfin"]["public_server"], 82 | validate=config.getboolean("password_validation", "enabled"), 83 | requirements=validator().getCriteria(), 84 | email=email, 85 | username=(not config.getboolean("email", "no_username")), 86 | ) 87 | elif "admin.html" not in path and "admin.html" not in path: 88 | return app.send_static_file(path) 89 | else: 90 | log.debug("Attempted use of invalid invite") 91 | return render_template( 92 | "invalidCode.html", 93 | bs5=config.getboolean("ui", "bs5"), 94 | css_file=css_file, 95 | contactMessage=config["ui"]["contact_message"], 96 | ) 97 | -------------------------------------------------------------------------------- /scss/bs4/bs4-jf.scss: -------------------------------------------------------------------------------- 1 | $jf-blue: rgb(0, 164, 220); 2 | $jf-blue-hover: rgba(0, 164, 220, 0.2); 3 | $jf-blue-focus: rgb(12, 176, 232); 4 | $jf-blue-light: #4bb3dd; 5 | 6 | $jf-red: rgb(204, 0, 0); 7 | $jf-red-light: #e12026; 8 | $jf-yellower: #ffc107; 9 | $jf-yellow: #e1b222; 10 | $jf-orange: #ff870f; 11 | $jf-green: #6fbd45; 12 | $jf-green-dark: #008040; 13 | 14 | 15 | $jf-black: #101010; // 16 16 16 16 | $jf-gray-90: #202020; // 32 32 32 17 | $jf-gray-80: #242424; // jf-card 36 36 36 18 | $jf-gray-70: #292929; // jf-input 41 41 41 19 | $jf-gray-60: #303030; // jf-button 48 48 48 20 | $jf-gray-50: #383838; // jf-button-focus 56 56 56 21 | $jf-text-bold: rgba(255, 255, 255, 0.87); 22 | $jf-text-primary: rgba(255, 255, 255, 0.8); 23 | $jf-text-secondary: rgb(153, 153, 153); 24 | 25 | $primary: $jf-blue; 26 | $secondary: $jf-gray-50; 27 | $success: $jf-green-dark; 28 | $danger: $jf-red-light; 29 | $light: $jf-text-primary; 30 | $dark: $jf-gray-90; 31 | $info: $jf-yellow; 32 | $warning: $jf-yellower; 33 | 34 | 35 | 36 | $enable-gradients: false; 37 | $enable-shadows: false; 38 | 39 | $enable-rounded: false; 40 | $body-bg: $jf-black; 41 | $body-color: $jf-text-primary; 42 | $border-color: $jf-gray-60; 43 | $component-active-color: $jf-text-bold; 44 | $component-active-bg: $jf-blue-focus; 45 | $text-muted: $jf-text-secondary; 46 | $link-color: $jf-blue-focus; 47 | $btn-link-disabled-color: $jf-text-secondary; 48 | $input-bg: $jf-gray-90; 49 | $input-color: $jf-text-primary; 50 | $input-focus-bg: $jf-gray-60; 51 | $input-focus-border-color: $jf-blue-focus; 52 | $input-disabled-bg: $jf-gray-70; 53 | input:disabled { 54 | color: $text-muted; 55 | } 56 | $input-border-color: $jf-gray-60; 57 | $input-placeholder-color: $text-muted; 58 | 59 | $form-check-input-bg: $jf-gray-60; 60 | $form-check-input-border: $jf-gray-50; 61 | $form-check-input-checked-color: $jf-blue-focus; 62 | $form-check-input-checked-bg-color: $jf-blue-hover; 63 | 64 | $input-group-addon-bg: $input-bg; 65 | 66 | $form-select-disabled-color: $jf-text-secondary; 67 | $form-select-disabled-bg: $input-disabled-bg; 68 | $form-select-indicator-color: $jf-gray-50; 69 | 70 | $card-bg: $jf-gray-80; 71 | $card-border-color: null; 72 | 73 | $tooltip-color: $jf-text-bold; 74 | $tooltip-bg: $jf-gray-50; 75 | 76 | $modal-content-bg: $jf-gray-80; 77 | $modal-content-border-color: $jf-gray-50; 78 | $modal-header-border-color: null; 79 | $modal-footer-border-color: null; 80 | 81 | $list-group-bg: $card-bg; 82 | $list-group-border-color: $jf-gray-50; 83 | $list-group-hover-bg: $jf-blue-hover; 84 | $list-group-active-bg: $jf-blue-focus; 85 | $list-group-action-color: $jf-text-primary; 86 | $list-group-action-hover-color: $jf-text-bold; 87 | $list-group-action-active-color: $jf-text-bold; 88 | $list-group-action-active-bg: $jf-blue-focus; 89 | 90 | // idk why but i had to put these above and below the import 91 | .list-group-item-danger { 92 | color: $jf-text-bold; 93 | background-color: $danger; 94 | } 95 | 96 | .list-group-item-success { 97 | color: $jf-text-bold; 98 | background-color: $success; 99 | } 100 | 101 | @import "../../node_modules/bootstrap4/scss/bootstrap"; 102 | 103 | .btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active { 104 | color: $jf-text-bold; 105 | } 106 | 107 | .close { 108 | color: $jf-text-secondary; 109 | } 110 | 111 | .close:hover, .close:active { 112 | color: $jf-text-primary; 113 | } 114 | 115 | .icon-button { 116 | color: $text-muted; 117 | } 118 | 119 | .icon-button:hover { 120 | color: $jf-text-bold; 121 | } 122 | 123 | .icon-button { 124 | color: $text-muted; 125 | } 126 | 127 | .text-bright { 128 | color: $jf-text-bold; 129 | } 130 | 131 | .list-group-item-danger { 132 | color: $jf-text-bold; 133 | background-color: $danger; 134 | } 135 | 136 | .list-group-item-success { 137 | color: $jf-text-bold; 138 | background-color: $success; 139 | } 140 | 141 | -------------------------------------------------------------------------------- /scss/bs5/bs5-jf.scss: -------------------------------------------------------------------------------- 1 | $jf-blue: rgb(0, 164, 220); 2 | $jf-blue-hover: rgba(0, 164, 220, 0.2); 3 | $jf-blue-focus: rgb(12, 176, 232); 4 | $jf-blue-light: #4bb3dd; 5 | 6 | $jf-red: rgb(204, 0, 0); 7 | $jf-red-light: #e12026; 8 | $jf-yellower: #ffc107; 9 | $jf-yellow: #e1b222; 10 | $jf-orange: #ff870f; 11 | $jf-green: #6fbd45; 12 | $jf-green-dark: #008040; 13 | 14 | 15 | $jf-black: #101010; // 16 16 16 16 | $jf-gray-90: #202020; // 32 32 32 17 | $jf-gray-80: #242424; // jf-card 36 36 36 18 | $jf-gray-70: #292929; // jf-input 41 41 41 19 | $jf-gray-60: #303030; // jf-button 48 48 48 20 | $jf-gray-50: #383838; // jf-button-focus 56 56 56 21 | $jf-text-bold: rgba(255, 255, 255, 0.87); 22 | $jf-text-primary: rgba(255, 255, 255, 0.8); 23 | $jf-text-secondary: rgb(153, 153, 153); 24 | 25 | $primary: $jf-blue; 26 | $secondary: $jf-gray-50; 27 | $success: $jf-green-dark; 28 | $danger: $jf-red-light; 29 | $light: $jf-text-primary; 30 | $dark: $jf-gray-90; 31 | $info: $jf-yellow; 32 | $warning: $jf-yellower; 33 | 34 | 35 | 36 | $enable-gradients: false; 37 | $enable-shadows: false; 38 | 39 | $enable-rounded: false; 40 | $body-bg: $jf-black; 41 | $body-color: $jf-text-primary; 42 | $border-color: $jf-gray-60; 43 | $component-active-color: $jf-text-bold; 44 | $component-active-bg: $jf-blue-focus; 45 | $text-muted: $jf-text-secondary; 46 | $link-color: $jf-blue-focus; 47 | $btn-link-disabled-color: $jf-text-secondary; 48 | $input-bg: $jf-gray-90; 49 | $input-color: $jf-text-primary; 50 | $input-focus-bg: $jf-gray-60; 51 | $input-focus-border-color: $jf-blue-focus; 52 | $input-disabled-bg: $jf-gray-70; 53 | input:disabled { 54 | color: $text-muted; 55 | } 56 | $input-border-color: $jf-gray-60; 57 | $input-placeholder-color: $text-muted; 58 | 59 | $form-check-input-bg: $jf-gray-60; 60 | $form-check-input-border: $jf-gray-50; 61 | $form-check-input-checked-color: $jf-blue-focus; 62 | $form-check-input-checked-bg-color: $jf-blue-hover; 63 | 64 | $input-group-addon-bg: $input-bg; 65 | 66 | $form-select-disabled-color: $jf-text-secondary; 67 | $form-select-disabled-bg: $input-disabled-bg; 68 | $form-select-indicator-color: $jf-gray-50; 69 | 70 | $card-bg: $jf-gray-80; 71 | $card-border-color: null; 72 | 73 | $tooltip-color: $jf-text-bold; 74 | $tooltip-bg: $jf-gray-50; 75 | 76 | $modal-content-bg: $jf-gray-80; 77 | $modal-content-border-color: $jf-gray-50; 78 | $modal-header-border-color: null; 79 | $modal-footer-border-color: null; 80 | 81 | $list-group-bg: $card-bg; 82 | $list-group-border-color: $jf-gray-50; 83 | $list-group-hover-bg: $jf-blue-hover; 84 | $list-group-active-bg: $jf-blue-focus; 85 | $list-group-action-color: $jf-text-primary; 86 | $list-group-action-hover-color: $jf-text-bold; 87 | $list-group-action-active-color: $jf-text-bold; 88 | $list-group-action-active-bg: $jf-blue-focus; 89 | 90 | // idk why but i had to put these above and below the import 91 | .list-group-item-danger { 92 | color: $jf-text-bold; 93 | background-color: $danger; 94 | } 95 | 96 | .list-group-item-success { 97 | color: $jf-text-bold; 98 | background-color: $success; 99 | } 100 | 101 | @import "../../node_modules/bootstrap/scss/bootstrap"; 102 | 103 | .btn-primary, .btn-outline-primary:hover, .btn-outline-primary:active { 104 | color: $jf-text-bold; 105 | } 106 | 107 | .close { 108 | color: $jf-text-secondary; 109 | } 110 | 111 | .close:hover, .close:active { 112 | color: $jf-text-primary; 113 | } 114 | 115 | .icon-button { 116 | color: $text-muted; 117 | } 118 | 119 | .icon-button:hover { 120 | color: $jf-text-bold; 121 | } 122 | 123 | .icon-button:active { 124 | color: $text-muted; 125 | } 126 | 127 | .text-bright { 128 | color: $jf-text-bold; 129 | } 130 | 131 | .list-group-item-danger { 132 | color: $jf-text-bold; 133 | background-color: $danger; 134 | } 135 | 136 | .list-group-item-success { 137 | color: $jf-text-bold; 138 | background-color: $success; 139 | } 140 | 141 | -------------------------------------------------------------------------------- /jellyfin_accounts/login.py: -------------------------------------------------------------------------------- 1 | # Handles authentication 2 | 3 | from flask_httpauth import HTTPBasicAuth 4 | from itsdangerous import ( 5 | TimedJSONWebSignatureSerializer as Serializer, 6 | BadSignature, 7 | SignatureExpired, 8 | ) 9 | from passlib.apps import custom_app_context as pwd_context 10 | import uuid 11 | from jellyfin_accounts import config, app, g 12 | from jellyfin_accounts import auth_log as log 13 | from jellyfin_accounts.jf_api import Jellyfin 14 | from jellyfin_accounts.web_api import jf 15 | 16 | auth_jf = Jellyfin( 17 | config["jellyfin"]["server"], 18 | config["jellyfin"]["client"], 19 | config["jellyfin"]["version"], 20 | config["jellyfin"]["device"], 21 | config["jellyfin"]["device_id"] + "_authClient", 22 | ) 23 | 24 | 25 | class Account: 26 | def __init__(self, username=None, password=None): 27 | self.username = username 28 | if password is not None: 29 | self.password_hash = pwd_context.hash(password) 30 | self.id = str(uuid.uuid4()) 31 | self.jf = False 32 | elif username is not None: 33 | jf.authenticate( 34 | config["jellyfin"]["username"], config["jellyfin"]["password"] 35 | ) 36 | self.id = jf.getUsers(self.username, public=False)["Id"] 37 | self.jf = True 38 | 39 | def verify_password(self, password): 40 | if not self.jf: 41 | return pwd_context.verify(password, self.password_hash) 42 | else: 43 | try: 44 | return auth_jf.authenticate(self.username, password) 45 | except Jellyfin.AuthenticationError: 46 | return False 47 | 48 | def generate_token(self, expiration=1200): 49 | s = Serializer(app.config["SECRET_KEY"], expires_in=expiration) 50 | log.debug(self.id) 51 | return s.dumps({"id": self.id}) 52 | 53 | @staticmethod 54 | def verify_token(token, accounts): 55 | log.debug(f"verifying token {token}") 56 | s = Serializer(app.config["SECRET_KEY"]) 57 | try: 58 | data = s.loads(token) 59 | except SignatureExpired: 60 | return None 61 | except BadSignature: 62 | return None 63 | if config.getboolean("ui", "jellyfin_login"): 64 | for account in accounts: 65 | if data["id"] == accounts[account].id: 66 | return account 67 | else: 68 | return accounts["adminAccount"] 69 | 70 | 71 | auth = HTTPBasicAuth() 72 | 73 | accounts = {} 74 | 75 | if config.getboolean("ui", "jellyfin_login"): 76 | log.debug("Using jellyfin for admin authentication") 77 | else: 78 | log.debug("Using configured login details for admin authentication") 79 | accounts["adminAccount"] = Account( 80 | config["ui"]["username"], config["ui"]["password"] 81 | ) 82 | 83 | 84 | @auth.verify_password 85 | def verify_password(username, password): 86 | user = None 87 | verified = False 88 | log.debug("Verifying auth") 89 | if config.getboolean("ui", "jellyfin_login"): 90 | try: 91 | jf_user = jf.getUsers(username, public=False) 92 | id = jf_user["Id"] 93 | user = accounts[id] 94 | except KeyError: 95 | if config.getboolean("ui", "admin_only"): 96 | if jf_user["Policy"]["IsAdministrator"]: 97 | user = Account(username) 98 | accounts[id] = user 99 | else: 100 | log.debug(f"User {username} not admin.") 101 | return False 102 | else: 103 | user = Account(username) 104 | accounts[id] = user 105 | except Jellyfin.UserNotFoundError: 106 | user = Account().verify_token(username, accounts) 107 | if user: 108 | verified = True 109 | if user in accounts: 110 | user = accounts[user] 111 | if not user: 112 | log.debug(f"User {username} not found on Jellyfin") 113 | return False 114 | else: 115 | user = accounts["adminAccount"] 116 | verified = Account().verify_token(username, accounts) 117 | if not verified: 118 | if username == user.username and user.verify_password(password): 119 | g.user = user 120 | log.debug("HTTPAuth Allowed") 121 | return user 122 | else: 123 | log.debug("HTTPAuth Denied") 124 | return False 125 | g.user = user 126 | log.debug("HTTPAuth Allowed") 127 | return user 128 | -------------------------------------------------------------------------------- /README.old.md: -------------------------------------------------------------------------------- 1 | # ![jellyfin-accounts](https://raw.githubusercontent.com/hrfee/jellyfin-accounts/bs5/images/jellyfin-accounts-banner-wide.svg) 2 | 3 | 4 | A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin). 5 | * Provides a web interface for creating/sending invites 6 | * Sends out emails when a user requests a password reset 7 | * Uses a basic python jellyfin API client for communication with the server. 8 | * 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) 9 | * Frontend uses [Bootstrap](https://v5.getbootstrap.com) 10 | * Password resets are handled using smtplib, requests, and [jinja](https://github.com/pallets/jinja) 11 | ## Interface 12 |

13 | 14 |

15 | 16 |

17 | Admin page 18 | Account creation page 19 |

20 | 21 | 22 | 23 | ## Get it 24 | ### Requirements 25 | 26 | * This should work anywhere Python does, i've tried to not use anything OS-specific. Drop an issue if there's a problem, of course. 27 | ``` 28 | * python >= 3.6 29 | * flask 30 | * flask_httpauth 31 | * jinja2 32 | * requests 33 | * itsdangerous 34 | * passlib 35 | * pyOpenSSL 36 | * waitress 37 | * pytz 38 | * python-dateutil 39 | * watchdog 40 | * packaging 41 | ``` 42 | ### Install 43 | 44 | Usually as simple as: 45 | ``` 46 | pip install jellyfin-accounts 47 | ``` 48 | If not, or if you want to use docker, see [install](https://github.com/hrfee/jellyfin-accounts/wiki/Install). 49 | 50 | ## Usage 51 | * Passing no arguments will run the server 52 | ``` 53 | usage: jf-accounts [-h] [-c CONFIG] [-d DATA] [--host HOST] [-p PORT] [-g] 54 | 55 | jellyfin-accounts 56 | 57 | optional arguments: 58 | -h, --help show this help message and exit 59 | -c CONFIG, --config CONFIG 60 | specifies path to configuration file. 61 | -d DATA, --data DATA specifies directory to store data in. defaults to 62 | ~/.jf-accounts. 63 | --host HOST address to host web ui on. 64 | -p PORT, --port PORT port to host web ui on. 65 | -g, --get_defaults tool to grab a JF users policy (access, perms, etc.) 66 | and homescreen layout and output it as json to be used 67 | as a user template. 68 | ``` 69 | ## Setup 70 | #### New user template 71 | * 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. 72 | * 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. 73 | #### Emails/Password Resets 74 | * 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`. 75 | * **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.** 76 | * Currently, jellyfin-accounts supports generic SSL/TLS or STARTTLS secured SMTP, and the [mailgun](https://mailgun.com) REST API. 77 | * 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. 78 | 79 | ### Configuration 80 | * Note: Make sure to put this behind a reverse proxy with HTTPS. 81 | 82 | On first run, access the setup wizard at `0.0.0.0:8056`. When finished, restart the program. 83 | 84 | The configuration is stored at `~/.jf-accounts/config.ini`. Settings can be changed through the web UI, or by manually editing the file. 85 | 86 | For detailed descriptions of each setting, see [setup](https://github.com/hrfee/jellyfin-accounts/wiki/Setup). 87 | 88 | ### Donations 89 | 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. 90 | 91 | [Donate](https://www.paypal.me/hrfee) 92 | -------------------------------------------------------------------------------- /config-default.ini: -------------------------------------------------------------------------------- 1 | [jellyfin] 2 | ; settings for connecting to jellyfin 3 | ; it is recommended to create a limited admin account for this program. 4 | username = username 5 | password = password 6 | ; jellyfin server address. can be public, or local for security purposes. 7 | server = http://jellyfin.local:8096 8 | ; publicly accessible jellyfin address for invite form. leave blank to reuse the above address. 9 | public_server = https://jellyf.in:443 10 | ; this and below settings will show on the jellyfin dashboard when the program connects. you may as well leave them alone. 11 | client = jf-accounts 12 | version = 0.3.7 13 | device = jf-accounts 14 | device_id = jf-accounts-0.3.7 15 | 16 | [ui] 17 | ; settings related to the ui and program functionality. 18 | ; default appearance for all users. 19 | theme = Jellyfin (Dark) 20 | ; set 0.0.0.0 to run on localhost 21 | host = 0.0.0.0 22 | port = 8056 23 | ; enable this to use jellyfin users instead of the below username and pw. 24 | jellyfin_login = true 25 | ; allows only admin users on jellyfin to access the admin page. 26 | admin_only = true 27 | ; username for admin page (leave blank if using jellyfin_login) 28 | username = your username 29 | ; password for admin page (leave blank if using jellyfin_login) 30 | password = your password 31 | ; address to send notifications to (leave blank if using jellyfin_login) 32 | email = example@example.com 33 | debug = false 34 | ; displayed at bottom of all pages except admin 35 | contact_message = Need help? contact me. 36 | ; displayed at top of invite form. 37 | help_message = Enter your details to create an account. 38 | ; displayed when a user creates an account 39 | success_message = Your account has been created. Click below to continue to Jellyfin. 40 | ; use bootstrap 5 (currently in alpha). this also removes the need for jquery, so the page should load faster. 41 | bs5 = false 42 | 43 | [password_validation] 44 | ; password validation (minimum length, etc.) 45 | enabled = true 46 | min_length = 8 47 | upper = 1 48 | lower = 0 49 | number = 1 50 | special = 0 51 | 52 | [email] 53 | ; general email settings. ignore if not using email features. 54 | ; use email address from invite form as username on jellyfin. 55 | no_username = false 56 | use_24h = true 57 | ; date format used in emails. follows datetime.strftime format. 58 | date_format = %d/%m/%y 59 | ; message displayed at bottom of emails. 60 | message = Need help? contact me. 61 | ; method of sending email to use. 62 | method = smtp 63 | ; address to send emails from 64 | address = jellyfin@jellyf.in 65 | ; the name of the sender 66 | from = Jellyfin 67 | 68 | [password_resets] 69 | ; settings for the password reset handler. 70 | ; enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send reset pins 71 | enabled = true 72 | ; path to the folder jellyfin puts password-reset files. 73 | watch_directory = /path/to/jellyfin 74 | ; path to custom email html 75 | email_html = 76 | ; path to custom email in plain text 77 | email_text = 78 | ; subject of password reset emails. 79 | subject = Password Reset - Jellyfin 80 | 81 | [invite_emails] 82 | ; settings for sending invites directly to users. 83 | enabled = true 84 | ; path to custom email html 85 | email_html = 86 | ; path to custom email in plain text 87 | email_text = 88 | ; subject of invite emails. 89 | subject = Invite - Jellyfin 90 | ; base url for jf-accounts. this is necessary because using a reverse proxy means the program has no way of knowing the url itself. 91 | url_base = http://accounts.jellyf.in:8056/invite 92 | 93 | [notifications] 94 | ; notification related settings. 95 | ; enabling adds optional toggles to invites to notify on expiry and user creation. 96 | enabled = true 97 | ; path to expiry notification email html. 98 | expiry_html = 99 | ; path to expiry notification email in plaintext. 100 | expiry_text = 101 | ; path to user creation notification email html. 102 | created_html = 103 | ; path to user creation notification email in plaintext. 104 | created_text = 105 | 106 | [mailgun] 107 | ; mailgun api connection settings 108 | api_url = https://api.mailgun.net... 109 | api_key = your api key 110 | 111 | [smtp] 112 | ; smtp server connection settings. 113 | ; your email provider should provide different ports for each encryption method. generally 465 for ssl_tls, 587 for starttls. 114 | encryption = starttls 115 | ; smtp server address. 116 | server = smtp.jellyf.in 117 | port = 465 118 | password = smtp password 119 | 120 | [files] 121 | ; optional settings for changing storage locations. 122 | ; location of stored invites (json). 123 | invites = 124 | ; location of stored email addresses (json). 125 | emails = 126 | ; location of stored user policy template (json). 127 | user_template = 128 | ; location of stored user configuration template (used for setting homescreen layout) (json) 129 | user_configuration = 130 | ; location of stored displaypreferences template (also used for homescreen layout) (json) 131 | user_displayprefs = 132 | ; location of custom bootstrap css. 133 | custom_css = 134 | 135 | -------------------------------------------------------------------------------- /jellyfin_accounts/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import configparser 3 | import secrets 4 | from pathlib import Path 5 | 6 | 7 | class Config: 8 | """ 9 | Configuration object that can automatically reload modified settings. 10 | Behaves mostly like a dictionary. 11 | :param file: Path to config.ini, where parameters are set. 12 | :param instance: Used to identify specific jf-accounts instances in environment variables. 13 | :param data_dir: Path to directory with config, invites, templates, etc. 14 | :param local_dir: Path to internally stored config base, emails, etc. 15 | """ 16 | 17 | @staticmethod 18 | def load_config(config_path, data_dir, local_dir, log): 19 | # Lord forgive me for this mess 20 | config = configparser.RawConfigParser() 21 | config.read(config_path) 22 | for key in config["files"]: 23 | if config["files"][key] == "": 24 | if key != "custom_css": 25 | log.debug(f"Using default {key}") 26 | config["files"][key] = str(data_dir / (key + ".json")) 27 | 28 | for key in ["user_configuration", "user_displayprefs"]: 29 | if key not in config["files"]: 30 | log.debug(f"Using default {key}") 31 | config["files"][key] = str(data_dir / (key + ".json")) 32 | 33 | if "no_username" not in config["email"]: 34 | config["email"]["no_username"] = "false" 35 | log.debug("Set no_username to false") 36 | if ( 37 | "email_html" not in config["password_resets"] 38 | or config["password_resets"]["email_html"] == "" 39 | ): 40 | log.debug("Using default password reset email HTML template") 41 | config["password_resets"]["email_html"] = str(local_dir / "email.html") 42 | if ( 43 | "email_text" not in config["password_resets"] 44 | or config["password_resets"]["email_text"] == "" 45 | ): 46 | log.debug("Using default password reset email plaintext template") 47 | config["password_resets"]["email_text"] = str(local_dir / "email.txt") 48 | 49 | if ( 50 | "email_html" not in config["invite_emails"] 51 | or config["invite_emails"]["email_html"] == "" 52 | ): 53 | log.debug("Using default invite email HTML template") 54 | config["invite_emails"]["email_html"] = str(local_dir / "invite-email.html") 55 | if ( 56 | "email_text" not in config["invite_emails"] 57 | or config["invite_emails"]["email_text"] == "" 58 | ): 59 | log.debug("Using default invite email plaintext template") 60 | config["invite_emails"]["email_text"] = str(local_dir / "invite-email.txt") 61 | if ( 62 | "public_server" not in config["jellyfin"] 63 | or config["jellyfin"]["public_server"] == "" 64 | ): 65 | config["jellyfin"]["public_server"] = config["jellyfin"]["server"] 66 | if "bs5" not in config["ui"] or config["ui"]["bs5"] == "": 67 | config["ui"]["bs5"] = "false" 68 | if ( 69 | "expiry_html" not in config["notifications"] 70 | or config["notifications"]["expiry_html"] == "" 71 | ): 72 | log.debug("Using default expiry notification HTML template") 73 | config["notifications"]["expiry_html"] = str(local_dir / "expired.html") 74 | if ( 75 | "expiry_text" not in config["notifications"] 76 | or config["notifications"]["expiry_text"] == "" 77 | ): 78 | log.debug("Using default expiry notification plaintext template") 79 | config["notifications"]["expiry_text"] = str(local_dir / "expired.txt") 80 | if ( 81 | "created_html" not in config["notifications"] 82 | or config["notifications"]["created_html"] == "" 83 | ): 84 | log.debug("Using default user creation notification HTML template") 85 | config["notifications"]["created_html"] = str(local_dir / "created.html") 86 | if ( 87 | "created_text" not in config["notifications"] 88 | or config["notifications"]["created_text"] == "" 89 | ): 90 | log.debug("Using default user creation notification plaintext template") 91 | config["notifications"]["created_text"] = str(local_dir / "created.txt") 92 | 93 | return config 94 | 95 | def __init__(self, file, instance, data_dir, local_dir, log): 96 | self.config_path = Path(file) 97 | self.data_dir = data_dir 98 | self.local_dir = local_dir 99 | self.instance = instance 100 | self.log = log 101 | self.varname = f"JFA_{self.instance}_RELOADCONFIG" 102 | os.environ[self.varname] = "true" 103 | 104 | def __getitem__(self, key): 105 | if os.environ[self.varname] == "true": 106 | self.config = Config.load_config( 107 | self.config_path, self.data_dir, self.local_dir, self.log 108 | ) 109 | os.environ[self.varname] = "false" 110 | return self.config.__getitem__(key) 111 | 112 | def getboolean(self, sect, key): 113 | if os.environ[self.varname] == "true": 114 | self.config = Config.load_config( 115 | self.config_path, self.data_dir, self.local_dir, self.log 116 | ) 117 | os.environ[self.varname] = "false" 118 | return self.config.getboolean(sect, key) 119 | 120 | def trigger_reload(self): 121 | os.environ[self.varname] = "true" 122 | -------------------------------------------------------------------------------- /jellyfin_accounts/email.py: -------------------------------------------------------------------------------- 1 | # Handles everything related to emails 2 | import datetime 3 | import pytz 4 | import requests 5 | import smtplib 6 | import ssl 7 | from email.mime.text import MIMEText 8 | from email.mime.multipart import MIMEMultipart 9 | from pathlib import Path 10 | from dateutil import parser as date_parser 11 | from jinja2 import Template 12 | from jellyfin_accounts import config 13 | from jellyfin_accounts import email_log as log 14 | 15 | 16 | def format_datetime(dt): 17 | result = dt.strftime(config["email"]["date_format"]) 18 | if config.getboolean("email", "use_24h"): 19 | result += f' {dt.strftime("%H:%M")}' 20 | else: 21 | result += f' {dt.strftime("%I:%M %p")}' 22 | return result 23 | 24 | 25 | class Email: 26 | def __init__(self, address): 27 | self.address = address 28 | log.debug(f"{self.address}: Creating email") 29 | self.content = {} 30 | self.from_address = config["email"]["address"] 31 | self.from_name = config["email"]["from"] 32 | log.debug( 33 | ( 34 | f"{self.address}: Sending from {self.from_address} " 35 | + f"({self.from_name})" 36 | ) 37 | ) 38 | # sp = Path(config["invite_emails"]["email_ 39 | # template_loader = FileSystemLoader(searchpath=sp) 40 | # template_loader = PackageLoader("jellyfin_accounts", "data") 41 | # self.template_env = Environment(loader=template_loader) 42 | 43 | def pretty_time(self, expiry, tzaware=False): 44 | if tzaware: 45 | current_time = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) 46 | else: 47 | current_time = datetime.datetime.now() 48 | date = expiry.strftime(config["email"]["date_format"]) 49 | if config.getboolean("email", "use_24h"): 50 | log.debug(f"{self.address}: Using 24h time") 51 | time = expiry.strftime("%H:%M") 52 | else: 53 | log.debug(f"{self.address}: Using 12h time") 54 | time = expiry.strftime("%-I:%M %p") 55 | expiry_delta = (expiry - current_time).seconds 56 | expires_in = { 57 | "hours": expiry_delta // 3600, 58 | "minutes": (expiry_delta // 60) % 60, 59 | } 60 | if expires_in["hours"] == 0: 61 | expires_in = f'{str(expires_in["minutes"])}m' 62 | else: 63 | expires_in = f'{str(expires_in["hours"])}h {str(expires_in["minutes"])}m' 64 | log.debug(f"{self.address}: Expires in {expires_in}") 65 | return {"date": date, "time": time, "expires_in": expires_in} 66 | 67 | def construct_invite(self, invite): 68 | self.subject = config["invite_emails"]["subject"] 69 | log.debug(f"{self.address}: Using subject {self.subject}") 70 | log.debug(f"{self.address}: Constructing email content") 71 | expiry = invite["expiry"] 72 | expiry.replace(tzinfo=None) 73 | pretty = self.pretty_time(expiry) 74 | email_message = config["email"]["message"] 75 | invite_link = config["invite_emails"]["url_base"] 76 | invite_link += "/" + invite["code"] 77 | for key in ["text", "html"]: 78 | fpath = Path(config["invite_emails"]["email_" + key]) 79 | with open(fpath, 'r') as f: 80 | template = Template(f.read()) 81 | c = template.render( 82 | expiry_date=pretty["date"], 83 | expiry_time=pretty["time"], 84 | expires_in=pretty["expires_in"], 85 | invite_link=invite_link, 86 | message=email_message, 87 | ) 88 | self.content[key] = c 89 | log.info(f"{self.address}: {key} constructed") 90 | 91 | def construct_expiry(self, invite): 92 | self.subject = "Notice: Invite expired" 93 | log.debug(f'Constructing expiry notification for {invite["code"]}') 94 | expiry = format_datetime(invite["expiry"]) 95 | for key in ["text", "html"]: 96 | fpath = Path(config["notifications"]["expiry_" + key]) 97 | with open(fpath, 'r') as f: 98 | template = Template(f.read()) 99 | c = template.render(code=invite["code"], expiry=expiry) 100 | self.content[key] = c 101 | log.info(f"{self.address}: {key} constructed") 102 | return True 103 | 104 | def construct_created(self, invite): 105 | self.subject = "Notice: User created" 106 | log.debug(f'Constructing user creation notification for {invite["code"]}') 107 | created = format_datetime(invite["created"]) 108 | if config.getboolean("email", "no_username"): 109 | email = "n/a" 110 | else: 111 | email = invite["address"] 112 | for key in ["text", "html"]: 113 | fpath = Path(config["notifications"]["created_" + key]) 114 | with open(fpath, 'r') as f: 115 | template = Template(f.read()) 116 | c = template.render( 117 | code=invite["code"], 118 | username=invite["username"], 119 | address=email, 120 | time=created, 121 | ) 122 | self.content[key] = c 123 | log.info(f"{self.address}: {key} constructed") 124 | return True 125 | 126 | def construct_reset(self, reset): 127 | self.subject = config["password_resets"]["subject"] 128 | log.debug(f"{self.address}: Using subject {self.subject}") 129 | log.debug(f"{self.address}: Constructing email content") 130 | try: 131 | expiry = date_parser.parse(reset["ExpirationDate"]) 132 | except: 133 | log.error(f"{self.address}: Couldn't parse expiry time") 134 | return False 135 | current_time = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) 136 | if expiry >= current_time: 137 | log.debug(f"{self.address}: Invite valid") 138 | pretty = self.pretty_time(expiry, tzaware=True) 139 | email_message = config["email"]["message"] 140 | for key in ["text", "html"]: 141 | fpath = Path(config["password_resets"]["email_" + key]) 142 | with open(fpath, 'r') as f: 143 | template = Template(f.read()) 144 | c = template.render( 145 | username=reset["UserName"], 146 | expiry_date=pretty["date"], 147 | expiry_time=pretty["time"], 148 | expires_in=pretty["expires_in"], 149 | pin=reset["Pin"], 150 | message=email_message, 151 | ) 152 | self.content[key] = c 153 | log.info(f"{self.address}: {key} constructed") 154 | return True 155 | else: 156 | err = ( 157 | f"{self.address}: " 158 | + "Reset has reportedly already expired. " 159 | + "Ensure timezones are correctly configured." 160 | ) 161 | log.error(err) 162 | return False 163 | 164 | 165 | class Mailgun(Email): 166 | errors = { 167 | 400: "Mailgun failed with 400: Bad request", 168 | 401: "Mailgun failed with 401: Invalid API key", 169 | } 170 | 171 | def __init__(self, address): 172 | super().__init__(address) 173 | self.api_url = config["mailgun"]["api_url"] 174 | self.api_key = config["mailgun"]["api_key"] 175 | self.from_mg = f"{self.from_name} <{self.from_address}>" 176 | 177 | def send(self): 178 | response = requests.post( 179 | self.api_url, 180 | auth=("api", self.api_key), 181 | data={ 182 | "from": self.from_mg, 183 | "to": [self.address], 184 | "subject": self.subject, 185 | "text": self.content["text"], 186 | "html": self.content["html"], 187 | }, 188 | ) 189 | if response.ok: 190 | log.info(f"{self.address}: Sent via mailgun.") 191 | return True 192 | elif response.status_code in Mailgun.errors: 193 | log.error(f"{self.address}: {Mailgun.errors[response.status_code]}") 194 | else: 195 | log.error( 196 | f"{self.address}: Mailgun failed with error {response.status_code}" 197 | ) 198 | return response 199 | 200 | 201 | class Smtp(Email): 202 | def __init__(self, address): 203 | super().__init__(address) 204 | self.server = config["smtp"]["server"] 205 | self.password = config["smtp"]["password"] 206 | try: 207 | self.port = int(config["smtp"]["port"]) 208 | except ValueError: 209 | self.port = 465 210 | log.debug(f"{self.address}: Defaulting to port {self.port}") 211 | 212 | def send(self): 213 | message = MIMEMultipart("alternative") 214 | message["Subject"] = self.subject 215 | message["From"] = self.from_address 216 | message["To"] = self.address 217 | text = MIMEText(self.content["text"], "plain") 218 | html = MIMEText(self.content["html"], "html") 219 | message.attach(text) 220 | message.attach(html) 221 | try: 222 | if config["smtp"]["encryption"] == "ssl_tls": 223 | self.context = ssl.create_default_context() 224 | with smtplib.SMTP_SSL( 225 | self.server, self.port, context=self.context 226 | ) as server: 227 | server.ehlo() 228 | server.login(self.from_address, self.password) 229 | server.sendmail( 230 | self.from_address, self.address, message.as_string() 231 | ) 232 | log.info(f"{self.address}: Sent via smtp (ssl/tls)") 233 | return True 234 | elif config["smtp"]["encryption"] == "starttls": 235 | with smtplib.SMTP(self.server, self.port) as server: 236 | server.ehlo() 237 | server.starttls() 238 | server.login(self.from_address, self.password) 239 | server.sendmail( 240 | self.from_address, self.address, message.as_string() 241 | ) 242 | log.info(f"{self.address}: Sent via smtp (starttls)") 243 | return True 244 | except Exception as e: 245 | log.error( 246 | f"{self.address}: Failed to send via smtp ({type(e).__name__}: {e.args})" 247 | ) 248 | try: 249 | log.error(e.smtp_error) 250 | except: 251 | pass 252 | return False 253 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2020.4.5.2 \ 2 | --hash=sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc \ 3 | --hash=sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1 4 | cffi==1.14.0 \ 5 | --hash=sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384 \ 6 | --hash=sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30 \ 7 | --hash=sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c \ 8 | --hash=sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78 \ 9 | --hash=sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793 \ 10 | --hash=sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e \ 11 | --hash=sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a \ 12 | --hash=sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff \ 13 | --hash=sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f \ 14 | --hash=sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa \ 15 | --hash=sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5 \ 16 | --hash=sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4 \ 17 | --hash=sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d \ 18 | --hash=sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc \ 19 | --hash=sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac \ 20 | --hash=sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f \ 21 | --hash=sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b \ 22 | --hash=sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3 \ 23 | --hash=sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66 \ 24 | --hash=sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0 \ 25 | --hash=sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f \ 26 | --hash=sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26 \ 27 | --hash=sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd \ 28 | --hash=sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55 \ 29 | --hash=sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2 \ 30 | --hash=sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8 \ 31 | --hash=sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b \ 32 | --hash=sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6 33 | chardet==3.0.4 \ 34 | --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \ 35 | --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae 36 | click==7.1.2 \ 37 | --hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc \ 38 | --hash=sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a 39 | cryptography==2.9.2 \ 40 | --hash=sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e \ 41 | --hash=sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b \ 42 | --hash=sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365 \ 43 | --hash=sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0 \ 44 | --hash=sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55 \ 45 | --hash=sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270 \ 46 | --hash=sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf \ 47 | --hash=sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d \ 48 | --hash=sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785 \ 49 | --hash=sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b \ 50 | --hash=sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae \ 51 | --hash=sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b \ 52 | --hash=sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6 \ 53 | --hash=sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3 \ 54 | --hash=sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b \ 55 | --hash=sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e \ 56 | --hash=sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0 \ 57 | --hash=sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5 \ 58 | --hash=sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229 59 | flask==1.1.2 \ 60 | --hash=sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557 \ 61 | --hash=sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060 62 | flask-httpauth==3.3.0 \ 63 | --hash=sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9 \ 64 | --hash=sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72 65 | idna==2.9 \ 66 | --hash=sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa \ 67 | --hash=sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb 68 | itsdangerous==1.1.0 \ 69 | --hash=sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749 \ 70 | --hash=sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19 71 | jinja2==2.11.2 \ 72 | --hash=sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035 \ 73 | --hash=sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0 74 | markupsafe==1.1.1 \ 75 | --hash=sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161 \ 76 | --hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7 \ 77 | --hash=sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183 \ 78 | --hash=sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b \ 79 | --hash=sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e \ 80 | --hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \ 81 | --hash=sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1 \ 82 | --hash=sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5 \ 83 | --hash=sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1 \ 84 | --hash=sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735 \ 85 | --hash=sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21 \ 86 | --hash=sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235 \ 87 | --hash=sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b \ 88 | --hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \ 89 | --hash=sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905 \ 90 | --hash=sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1 \ 91 | --hash=sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d \ 92 | --hash=sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff \ 93 | --hash=sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473 \ 94 | --hash=sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e \ 95 | --hash=sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66 \ 96 | --hash=sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5 \ 97 | --hash=sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d \ 98 | --hash=sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e \ 99 | --hash=sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6 \ 100 | --hash=sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2 \ 101 | --hash=sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c \ 102 | --hash=sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15 \ 103 | --hash=sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2 \ 104 | --hash=sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42 \ 105 | --hash=sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b \ 106 | --hash=sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be \ 107 | --hash=sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b 108 | packaging==20.4 \ 109 | --hash=sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181 \ 110 | --hash=sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8 111 | passlib==1.7.2 \ 112 | --hash=sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177 \ 113 | --hash=sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8 114 | pathtools==0.1.2 \ 115 | --hash=sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0 116 | pycparser==2.20 \ 117 | --hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705 \ 118 | --hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0 119 | pyopenssl==19.1.0 \ 120 | --hash=sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504 \ 121 | --hash=sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507 122 | pyparsing==2.4.7 \ 123 | --hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b \ 124 | --hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1 125 | python-dateutil==2.8.1 \ 126 | --hash=sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c \ 127 | --hash=sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a 128 | pytz==2020.1 \ 129 | --hash=sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed \ 130 | --hash=sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048 131 | requests==2.23.0 \ 132 | --hash=sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee \ 133 | --hash=sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 134 | six==1.15.0 \ 135 | --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced \ 136 | --hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 137 | urllib3==1.25.9 \ 138 | --hash=sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115 \ 139 | --hash=sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527 140 | waitress==1.4.4 \ 141 | --hash=sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db \ 142 | --hash=sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261 143 | watchdog==0.10.2 \ 144 | --hash=sha256:c560efb643faed5ef28784b2245cf8874f939569717a4a12826a173ac644456b 145 | werkzeug==1.0.1 \ 146 | --hash=sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43 \ 147 | --hash=sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c 148 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/static/setup.js: -------------------------------------------------------------------------------- 1 | function checkAuthRadio() { 2 | if (document.getElementById('manualAuthRadio').checked) { 3 | document.getElementById('adminOnlyArea').style.display = 'none'; 4 | document.getElementById('manualAuthArea').style.display = ''; 5 | } else { 6 | document.getElementById('manualAuthArea').style.display = 'none'; 7 | document.getElementById('adminOnlyArea').style.display = ''; 8 | }; 9 | }; 10 | var authRadios = ['manualAuthRadio', 'jfAuthRadio']; 11 | for (var i = 0; i < authRadios.length; i++) { 12 | document.getElementById(authRadios[i]).addEventListener('change', function() { 13 | checkAuthRadio(); 14 | }); 15 | }; 16 | 17 | function checkEmailRadio() { 18 | document.getElementById('emailNextButton').href = '#page-5'; 19 | document.getElementById('valBackButton').href = '#page-7'; 20 | if (document.getElementById('emailSMTPRadio').checked) { 21 | document.getElementById('emailCommonArea').style.display = ''; 22 | document.getElementById('emailSMTPArea').style.display = ''; 23 | document.getElementById('emailMailgunArea').style.display = 'none'; 24 | document.getElementById('notificationsEnabled').checked = true; 25 | } else if (document.getElementById('emailMailgunRadio').checked) { 26 | document.getElementById('emailCommonArea').style.display = ''; 27 | document.getElementById('emailSMTPArea').style.display = 'none'; 28 | document.getElementById('emailMailgunArea').style.display = ''; 29 | document.getElementById('notificationsEnabled').checked = true; 30 | } else if (document.getElementById('emailDisabledRadio').checked) { 31 | document.getElementById('emailCommonArea').style.display = 'none'; 32 | document.getElementById('emailSMTPArea').style.display = 'none'; 33 | document.getElementById('emailMailgunArea').style.display = 'none'; 34 | document.getElementById('emailNextButton').href = '#page-8'; 35 | document.getElementById('valBackButton').href = '#page-4'; 36 | document.getElementById('notificationsEnabled').checked = false; 37 | }; 38 | }; 39 | var emailRadios = ['emailDisabledRadio', 'emailSMTPRadio', 'emailMailgunRadio']; 40 | for (var i = 0; i < emailRadios.length; i++) { 41 | document.getElementById(emailRadios[i]).addEventListener('change', function() { 42 | checkEmailRadio(); 43 | }); 44 | }; 45 | 46 | function checkSSL() { 47 | var label = document.getElementById('emailSSL_TLSLabel'); 48 | if (document.getElementById('emailSSL_TLS').checked) { 49 | label.textContent = 'Use SSL/TLS'; 50 | } else { 51 | label.textContent = 'Use STARTTLS'; 52 | }; 53 | }; 54 | document.getElementById('emailSSL_TLS').addEventListener('change', function() { 55 | checkSSL(); 56 | }); 57 | 58 | function checkPwrEnabled() { 59 | if (document.getElementById('pwrEnabled').checked) { 60 | document.getElementById('pwrArea').style.display = ''; 61 | } else { 62 | document.getElementById('pwrArea').style.display = 'none'; 63 | }; 64 | }; 65 | var pwrEnabled = document.getElementById('pwrEnabled'); 66 | pwrEnabled.addEventListener('change', function() { 67 | checkPwrEnabled(); 68 | }); 69 | 70 | function checkInvEnabled() { 71 | if (document.getElementById('invEnabled').checked) { 72 | document.getElementById('invArea').style.display = ''; 73 | } else { 74 | document.getElementById('invArea').style.display = 'none'; 75 | }; 76 | }; 77 | document.getElementById('invEnabled').addEventListener('change', function() { 78 | checkInvEnabled(); 79 | }); 80 | 81 | function checkValEnabled() { 82 | if (document.getElementById('valEnabled').checked) { 83 | document.getElementById('valArea').style.display = ''; 84 | } else { 85 | document.getElementById('valArea').style.display = 'none'; 86 | }; 87 | }; 88 | document.getElementById('valEnabled').addEventListener('change', function() { 89 | checkValEnabled(); 90 | }); 91 | checkValEnabled(); 92 | checkInvEnabled(); 93 | checkSSL(); 94 | checkAuthRadio(); 95 | checkEmailRadio(); 96 | checkPwrEnabled(); 97 | 98 | var jfValid = false 99 | document.getElementById('jfTestButton').onclick = function() { 100 | var testButton = document.getElementById('jfTestButton'); 101 | var nextButton = document.getElementById('jfNextButton'); 102 | testButton.disabled = true; 103 | testButton.innerHTML = 104 | '' + 105 | 'Testing...'; 106 | nextButton.classList.add('disabled'); 107 | nextButton.setAttribute('aria-disabled', 'true'); 108 | var jfData = {}; 109 | jfData['jfHost'] = document.getElementById('jfHost').value; 110 | jfData['jfUser'] = document.getElementById('jfUser').value; 111 | jfData['jfPassword'] = document.getElementById('jfPassword').value; 112 | var req = new XMLHttpRequest(); 113 | req.open("POST", "/testJF", true); 114 | req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); 115 | req.responseType = 'json'; 116 | req.onreadystatechange = function() { 117 | if (this.readyState == 4) { 118 | testButton.disabled = false; 119 | testButton.className = ''; 120 | if (this.response['success'] == true) { 121 | testButton.classList.add('btn', 'btn-success'); 122 | testButton.textContent = 'Success'; 123 | nextButton.classList.remove('disabled'); 124 | nextButton.setAttribute('aria-disabled', 'false'); 125 | } else { 126 | testButton.classList.add('btn', 'btn-danger'); 127 | testButton.textContent = 'Failed'; 128 | }; 129 | }; 130 | }; 131 | req.send(JSON.stringify(jfData)); 132 | }; 133 | 134 | document.getElementById('submitButton').onclick = function() { 135 | var submitButton = document.getElementById('submitButton'); 136 | submitButton.disabled = true; 137 | submitButton.innerHTML = 138 | '' + 139 | 'Submitting...'; 140 | var config = {}; 141 | config['jellyfin'] = {}; 142 | config['ui'] = {}; 143 | config['password_validation'] = {}; 144 | config['email'] = {}; 145 | config['password_resets'] = {}; 146 | config['invite_emails'] = {}; 147 | config['mailgun'] = {}; 148 | config['smtp'] = {}; 149 | config['notifications'] = {}; 150 | // Page 2: Auth 151 | if (document.getElementById('jfAuthRadio').checked) { 152 | config['ui']['jellyfin_login'] = 'true'; 153 | if (document.getElementById('jfAuthAdminOnly').checked) { 154 | config['ui']['admin_only'] = 'true'; 155 | } else { 156 | config['ui']['admin_only'] = 'false' 157 | }; 158 | } else { 159 | config['ui']['username'] = document.getElementById('manualAuthUsername').value; 160 | config['ui']['password'] = document.getElementById('manualAuthPassword').value; 161 | config['ui']['email'] = document.getElementById('manualAuthEmail').value; 162 | }; 163 | // Page 3: Connect to jellyfin 164 | config['jellyfin']['server'] = document.getElementById('jfHost').value; 165 | config['jellyfin']['username'] = document.getElementById('jfUser').value; 166 | config['jellyfin']['password'] = document.getElementById('jfPassword').value; 167 | // Page 4: Email (Page 5, 6, 7 are only used if this is enabled) 168 | if (document.getElementById('emailDisabledRadio').checked) { 169 | config['password_resets']['enabled'] = 'false'; 170 | config['invite_emails']['enabled'] = 'false'; 171 | config['notificatons']['enabled'] = 'false'; 172 | } else { 173 | if (document.getElementById('emailSMTPRadio').checked) { 174 | if (document.getElementById('emailSSL_TLS').checked) { 175 | config['smtp']['encryption'] = 'ssl_tls'; 176 | } else { 177 | config['smtp']['encryption'] = 'starttls'; 178 | }; 179 | config['email']['method'] = 'smtp'; 180 | config['smtp']['server'] = document.getElementById('emailSMTPServer').value; 181 | config['smtp']['port'] = document.getElementById('emailSMTPPort').value; 182 | config['smtp']['password'] = document.getElementById('emailSMTPPassword').value; 183 | config['email']['address'] = document.getElementById('emailSMTPAddress').value; 184 | } else { 185 | config['email']['method'] = 'mailgun'; 186 | config['mailgun']['api_url'] = document.getElementById('emailMailgunURL').value; 187 | config['mailgun']['api_key'] = document.getElementById('emailMailgunKey').value; 188 | config['email']['address'] = document.getElementById('emailMailgunAddress').value; 189 | }; 190 | config['notifications']['enabled'] = document.getElementById('notificationsEnabled').checked.toString(); 191 | // Page 5: Email formatting 192 | config['email']['from'] = document.getElementById('emailSender').value; 193 | config['email']['date_format'] = document.getElementById('emailDateFormat').value; 194 | if (document.getElementById('email24hTimeRadio').checked) { 195 | config['email']['use_24h'] = 'true'; 196 | } else { 197 | config['email']['use_24h'] = 'false'; 198 | }; 199 | config['email']['message'] = document.getElementById('emailMessage').value; 200 | // Page 6: Password Resets 201 | if (document.getElementById('pwrEnabled').checked) { 202 | config['password_resets']['enabled'] = 'true'; 203 | config['password_resets']['watch_directory'] = document.getElementById('pwrJfPath').value; 204 | config['password_resets']['subject'] = document.getElementById('pwrSubject').value; 205 | } else { 206 | config['password_resets']['enabled'] = 'false'; 207 | }; 208 | // Page 7: Invite Emails 209 | if (document.getElementById('invEnabled').checked) { 210 | config['invite_emails']['enabled'] = 'true'; 211 | config['invite_emails']['url_base'] = document.getElementById('invURLBase').value; 212 | config['invite_emails']['subject'] = document.getElementById('invSubject').value; 213 | } else { 214 | config['invite_emails']['enabled'] = 'false'; 215 | }; 216 | }; 217 | // Page 8: Password Validation 218 | if (document.getElementById('valEnabled').checked) { 219 | config['password_validation']['enabled'] = 'true'; 220 | config['password_validation']['min_length'] = document.getElementById('valLength').value; 221 | config['password_validation']['upper'] = document.getElementById('valUpper').value; 222 | config['password_validation']['lower'] = document.getElementById('valLower').value; 223 | config['password_validation']['number'] = document.getElementById('valNumber').value; 224 | config['password_validation']['special'] = document.getElementById('valSpecial').value; 225 | } else { 226 | config['password_validation']['enabled'] = 'false'; 227 | }; 228 | // Page 9: Messages 229 | config['ui']['contact_message'] = document.getElementById('msgContact').value; 230 | config['ui']['help_message'] = document.getElementById('msgHelp').value; 231 | config['ui']['success_message'] = document.getElementById('msgSuccess').value; 232 | // Send it 233 | var req = new XMLHttpRequest(); 234 | req.open("POST", "/modifyConfig", true); 235 | req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); 236 | req.responseType = 'json'; 237 | req.onreadystatechange = function() { 238 | if (this.readyState == 4) { 239 | submitButton.disabled = false; 240 | submitButton.className = ''; 241 | submitButton.classList.add('btn', 'btn-success'); 242 | submitButton.textContent = 'Success'; 243 | }; 244 | }; 245 | req.send(JSON.stringify(config)); 246 | }; 247 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/templates/form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% if not bs5 %} 18 | 19 | {% endif %} 20 | 21 | {% if bs5 %} 22 | 23 | {% else %} 24 | 25 | {% endif %} 26 | 27 | 44 | Create Jellyfin Account 45 | 46 | 47 | 62 |
63 |

64 | Create Account 65 |

66 |

{{ helpMessage }}

67 |

{{ contactMessage }}

68 |
69 |
70 |
71 |
72 |
Details
73 |
74 |
75 |
76 | 77 | 78 |
79 | {% if username %} 80 |
81 | 82 | 83 |
84 | {% endif %} 85 |
86 | 87 | 88 |
89 |
90 | 93 |
94 |
95 |
96 |
97 |
98 | {% if validate %} 99 |
100 |
101 |
Password Requirements
102 |
103 |
    104 | {% for key, value in requirements.items() %} 105 |
  • 106 |
    {{ value }}
    107 |
  • 108 | {% endfor %} 109 |
110 |
111 |
112 |
113 | {% endif %} 114 |
115 |
116 |
117 | 118 | 221 | 222 | 223 | -------------------------------------------------------------------------------- /jellyfin_accounts/jf_api.py: -------------------------------------------------------------------------------- 1 | # Jellyfin API client 2 | import requests 3 | import time 4 | 5 | 6 | class Error(Exception): 7 | pass 8 | 9 | 10 | class Jellyfin: 11 | """ 12 | Basic Jellyfin API client, providing account related function only. 13 | """ 14 | 15 | class UserExistsError(Error): 16 | """ 17 | Thrown if a user already exists with the same name 18 | when creating an account. 19 | """ 20 | 21 | pass 22 | 23 | class UserNotFoundError(Error): 24 | """Thrown if account with specified user ID/name does not exist.""" 25 | 26 | pass 27 | 28 | class AuthenticationError(Error): 29 | """Thrown if authentication with Jellyfin fails.""" 30 | 31 | pass 32 | 33 | class AuthenticationRequiredError(Error): 34 | """ 35 | Thrown if privileged action is attempted without authentication. 36 | """ 37 | 38 | pass 39 | 40 | class UnknownError(Error): 41 | """ 42 | Thrown if i've been too lazy to figure out an error's meaning. 43 | """ 44 | 45 | pass 46 | 47 | def __init__(self, server, client, version, device, deviceId, cacheMinutes=30): 48 | """ 49 | Initializes the Jellyfin object. All parameters except server 50 | have no effect on the client's capability. 51 | 52 | :param server: Web address of the server to connect to. 53 | :param client: Name of the client. Appears on Jellyfin 54 | server dashboard. 55 | :param version: Version of the client. 56 | :param device: Name of the device the client is running on. 57 | :param deviceId: ID of the device the client is running on. 58 | """ 59 | self.server = server 60 | self.client = client 61 | self.version = version 62 | self.device = device 63 | self.deviceId = deviceId 64 | self.authenticated = False 65 | self.timeout = cacheMinutes * 60 66 | self.userCacheAge = time.time() - self.timeout - 1 67 | self.userCachePublicAge = self.userCacheAge 68 | self.useragent = f"{self.client}/{self.version}" 69 | self.auth = "MediaBrowser " 70 | self.auth += f"Client={self.client}, " 71 | self.auth += f"Device={self.device}, " 72 | self.auth += f"DeviceId={self.deviceId}, " 73 | self.auth += f"Version={self.version}" 74 | self.header = { 75 | "Accept": "application/json", 76 | "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", 77 | "X-Application": f"{self.client}/{self.version}", 78 | "Accept-Charset": "UTF-8,*", 79 | "Accept-encoding": "gzip", 80 | "User-Agent": self.useragent, 81 | "X-Emby-Authorization": self.auth, 82 | } 83 | try: 84 | self.info = requests.get(f"{self.server}/System/Info/Public").json() 85 | except: 86 | pass 87 | 88 | def reloadCache(self): 89 | """ Forces a reload of the user caches """ 90 | self.userCachePublicAge = time.time() - self.timeout - 1 91 | self.getUsers() 92 | try: 93 | self.userCacheAge = self.userCachePublicAge 94 | self.getUsers(public=False) 95 | except self.AuthenticationRequiredError: 96 | pass 97 | 98 | def getUsers(self, username: str = "all", userId: str = "all", public: bool = True): 99 | """ 100 | Returns details on user(s), such as ID, Name, Policy. 101 | 102 | :param username: (optional) Username to get info about. 103 | Leave blank to get all users. 104 | :param userId: (optional) User ID to get info about. 105 | Leave blank to get all users. 106 | :param public: True = Get publicly visible users only (no auth required), 107 | False = Get all users (auth required). 108 | """ 109 | if public is True: 110 | if (time.time() - self.userCachePublicAge) >= self.timeout: 111 | response = requests.get(f"{self.server}/Users/Public").json() 112 | self.userCachePublic = response 113 | self.userCachePublicAge = time.time() 114 | else: 115 | response = self.userCachePublic 116 | elif ( 117 | public is False and hasattr(self, "username") and hasattr(self, "password") 118 | ): 119 | if (time.time() - self.userCacheAge) >= self.timeout: 120 | response = requests.get( 121 | f"{self.server}/Users", 122 | headers=self.header, 123 | params={"Username": self.username, "Pw": self.password}, 124 | ) 125 | if response.status_code == 200: 126 | response = response.json() 127 | self.userCache = response 128 | self.userCacheAge = time.time() 129 | elif response.status_code == 401: 130 | try: 131 | self.authenticate(self.username, self.password) 132 | return self.getUsers(username, userId, public) 133 | except self.AuthenticationError: 134 | raise self.AuthenticationRequiredError 135 | else: 136 | response = self.userCache 137 | else: 138 | raise self.AuthenticationRequiredError 139 | if username == "all" and userId == "all": 140 | return response 141 | elif userId == "all": 142 | match = False 143 | for user in response: 144 | if user["Name"] == username: 145 | match = True 146 | return user 147 | if not match: 148 | raise self.UserNotFoundError 149 | else: 150 | match = False 151 | for user in response: 152 | if user["Id"] == userId: 153 | match = True 154 | return user 155 | if not match: 156 | raise self.UserNotFoundError 157 | 158 | def authenticate(self, username: str, password: str): 159 | """ 160 | Authenticates by name with Jellyfin. 161 | 162 | :param username: Plaintext username. 163 | :param password: Plaintext password. 164 | """ 165 | response = requests.post( 166 | f"{self.server}/Users/AuthenticateByName", 167 | headers=self.header, 168 | params={"Username": username, "Pw": password}, 169 | ) 170 | if response.status_code == 200: 171 | json = response.json() 172 | self.userId = json["User"]["Id"] 173 | self.accessToken = json["AccessToken"] 174 | self.auth = "MediaBrowser " 175 | self.auth += f"Client={self.client}, " 176 | self.auth += f"Device={self.device}, " 177 | self.auth += f"DeviceId={self.deviceId}, " 178 | self.auth += f"Version={self.version}" 179 | self.auth += f", Token={self.accessToken}" 180 | self.header["X-Emby-Authorization"] = self.auth 181 | self.info = requests.get( 182 | f"{self.server}/System/Info", headers=self.header 183 | ).json() 184 | self.username = username 185 | self.password = password 186 | self.authenticated = True 187 | return True 188 | else: 189 | raise self.AuthenticationError 190 | 191 | def setPolicy(self, userId: str, policy: dict): 192 | """ 193 | Sets a user's policy (Admin rights, Library Access, etc.) by user ID. 194 | 195 | :param userId: ID of the user to modify. 196 | :param policy: User policy in dictionary form. 197 | """ 198 | return requests.post( 199 | f"{self.server}/Users/" + userId + "/Policy", 200 | headers=self.header, 201 | params=policy, 202 | ) 203 | 204 | def newUser(self, username: str, password: str): 205 | for user in self.getUsers(public=False): 206 | if user["Name"] == username: 207 | raise self.UserExistsError 208 | response = requests.post( 209 | f"{self.server}/Users/New", 210 | headers=self.header, 211 | params={"Name": username, "Password": password}, 212 | ) 213 | if response.status_code == 401: 214 | if hasattr(self, "username") and hasattr(self, "password"): 215 | self.authenticate(self.username, self.password) 216 | return self.newUser(username, password) 217 | else: 218 | raise self.AuthenticationRequiredError 219 | return response 220 | 221 | def getViewOrder(self, userId: str, public: bool = True): 222 | if not public: 223 | param = "?IncludeHidden=true" 224 | else: 225 | param = "" 226 | views = requests.get( 227 | f"{self.server}/Users/" + userId + "/Views" + param, headers=self.header 228 | ).json()["Items"] 229 | orderedViews = [] 230 | for library in views: 231 | orderedViews.append(library["Id"]) 232 | return orderedViews 233 | 234 | def setConfiguration(self, userId: str, configuration: dict): 235 | """ 236 | Sets a user's configuration (Settings the user can change themselves). 237 | :param userId: ID of the user to modify. 238 | :param configuration: Configuration to write in dictionary form. 239 | """ 240 | resp = requests.post( 241 | f"{self.server}/Users/" + userId + "/Configuration", 242 | headers=self.header, 243 | params=configuration, 244 | ) 245 | if resp.status_code == 200 or resp.status_code == 204: 246 | return True 247 | elif resp.status_code == 401: 248 | if hasattr(self, "username") and hasattr(self, "password"): 249 | self.authenticate(self.username, self.password) 250 | return self.setConfiguration(userId, configuration) 251 | else: 252 | raise self.AuthenticationRequiredError 253 | else: 254 | raise self.UnknownError 255 | 256 | def getConfiguration(self, username: str = "all", userId: str = "all"): 257 | """ 258 | Gets a user's Configuration. This can also be found in getUsers if 259 | public is set to False. 260 | :param username: The user's username. 261 | :param userId: The user's ID. 262 | """ 263 | return self.getUsers(username=username, userId=userId, public=False)[ 264 | "Configuration" 265 | ] 266 | 267 | def getDisplayPreferences(self, userId: str): 268 | """ 269 | Gets a user's Display Preferences (Home layout). 270 | :param userId: The user's ID. 271 | """ 272 | resp = requests.get( 273 | ( 274 | self.server 275 | + "/DisplayPreferences/usersettings" 276 | + "?userId=" 277 | + userId 278 | + "&client=emby" 279 | ), 280 | headers=self.header, 281 | ) 282 | if resp.status_code == 200: 283 | return resp.json() 284 | elif resp.status_code == 401: 285 | if hasattr(self, "username") and hasattr(self, "password"): 286 | self.authenticate(self.username, self.password) 287 | return self.getDisplayPreferences(userId) 288 | else: 289 | raise self.AuthenticationRequiredError 290 | else: 291 | raise self.UnknownError 292 | 293 | def setDisplayPreferences(self, userId: str, preferences: dict): 294 | """ 295 | Sets a user's Display Preferences (Home layout). 296 | :param userId: The user's ID. 297 | :param preferences: The preferences to set. 298 | """ 299 | tempheader = self.header 300 | tempheader["Content-type"] = "application/json" 301 | resp = requests.post( 302 | ( 303 | self.server 304 | + "/DisplayPreferences/usersettings" 305 | + "?userId=" 306 | + userId 307 | + "&client=emby" 308 | ), 309 | headers=tempheader, 310 | json=preferences, 311 | ) 312 | if resp.status_code == 200 or resp.status_code == 204: 313 | return True 314 | elif resp.status_code == 401: 315 | if hasattr(self, "username") and hasattr(self, "password"): 316 | self.authenticate(self.username, self.password) 317 | return self.setDisplayPreferences(userId, preferences) 318 | else: 319 | raise self.AuthenticationRequiredError 320 | else: 321 | return resp 322 | -------------------------------------------------------------------------------- /jellyfin_accounts/__init__.py: -------------------------------------------------------------------------------- 1 | # Runs it! 2 | __version__ = "0.3.9" 3 | 4 | print("Note: jellyfin-accounts has been deprecated. Try jfa-go, a rewrite thats fast, portable, and has more features. Find it at\nhttps://github.com/hrfee/jfa-go\n") 5 | 6 | import secrets 7 | import configparser 8 | import shutil 9 | import argparse 10 | import logging 11 | import threading 12 | import signal 13 | import sys 14 | import json 15 | from pathlib import Path 16 | from flask import Flask, jsonify, g 17 | from jellyfin_accounts.data_store import JSONStorage 18 | from jellyfin_accounts.config import Config 19 | 20 | parser = argparse.ArgumentParser(description="jellyfin-accounts") 21 | 22 | parser.add_argument("-c", "--config", help="specifies path to configuration file.") 23 | parser.add_argument( 24 | "-d", 25 | "--data", 26 | help=("specifies directory to store data in. " + "defaults to ~/.jf-accounts."), 27 | ) 28 | parser.add_argument("--host", help="address to host web ui on.") 29 | parser.add_argument("-p", "--port", help="port to host web ui on.") 30 | parser.add_argument( 31 | "-g", 32 | "--get_defaults", 33 | help=( 34 | "tool to grab a JF users " 35 | + "policy (access, perms, etc.) and " 36 | + "homescreen layout and " 37 | + "output it as json to be used as a user template." 38 | ), 39 | action="store_true", 40 | ) 41 | parser.add_argument( 42 | "-i", "--install", help="attempt to install a system service.", action="store_true" 43 | ) 44 | 45 | args, leftovers = parser.parse_known_args() 46 | 47 | if args.data is not None: 48 | data_dir = Path(args.data) 49 | else: 50 | data_dir = Path.home() / ".jf-accounts" 51 | 52 | local_dir = (Path(__file__).parent / "data").resolve() 53 | config_base_path = local_dir / "config-base.json" 54 | 55 | first_run = False 56 | if data_dir.exists() is False or (data_dir / "config.ini").exists() is False: 57 | if not data_dir.exists(): 58 | Path.mkdir(data_dir) 59 | print(f"Config dir not found, so generating at {str(data_dir)}") 60 | if args.config is None: 61 | config_path = data_dir / "config.ini" 62 | from jellyfin_accounts.generate_ini import generate_ini 63 | 64 | default_path = local_dir / "config-default.ini" 65 | generate_ini(config_base_path, default_path, __version__) 66 | shutil.copy(str(default_path), str(config_path)) 67 | print("Setup through the web UI, or quit and edit the configuration manually.") 68 | first_run = True 69 | else: 70 | config_path = Path(args.config) 71 | print(f"config.ini can be found at {str(config_path)}") 72 | else: 73 | config_path = data_dir / "config.ini" 74 | 75 | # Temp config so logger knows whether to use debug mode or not 76 | temp_config = configparser.RawConfigParser() 77 | temp_config.read(str(config_path.resolve())) 78 | 79 | 80 | def create_log(name): 81 | log = logging.getLogger(name) 82 | handler = logging.StreamHandler(sys.stdout) 83 | if temp_config.getboolean("ui", "debug"): 84 | log.setLevel(logging.DEBUG) 85 | handler.setLevel(logging.DEBUG) 86 | else: 87 | log.setLevel(logging.INFO) 88 | handler.setLevel(logging.INFO) 89 | fmt = " %(name)s - %(levelname)s - %(message)s" 90 | format = logging.Formatter(fmt) 91 | handler.setFormatter(format) 92 | log.addHandler(handler) 93 | log.propagate = False 94 | return log 95 | 96 | 97 | log = create_log("main") 98 | 99 | config = Config(config_path, secrets.token_urlsafe(16), data_dir, local_dir, log) 100 | 101 | web_log = create_log("waitress") 102 | if not first_run: 103 | email_log = create_log("email") 104 | pwr_log = create_log("pwr") 105 | auth_log = create_log("auth") 106 | 107 | if args.host is not None: 108 | log.debug(f"Using specified host {args.host}") 109 | config["ui"]["host"] = args.host 110 | if args.port is not None: 111 | log.debug(f"Using specified port {args.port}") 112 | config["ui"]["port"] = args.port 113 | 114 | 115 | try: 116 | with open(config["files"]["invites"], "r") as f: 117 | temp_invites = json.load(f) 118 | if "invites" in temp_invites: 119 | new_invites = {} 120 | log.info("Converting invites.json to new format, temporary.") 121 | for el in temp_invites["invites"]: 122 | i = {"valid_till": el["valid_till"]} 123 | if "email" in el: 124 | i["email"] = el["email"] 125 | new_invites[el["code"]] = i 126 | with open(config["files"]["invites"], "w") as f: 127 | f.write(json.dumps(new_invites, indent=4, default=str)) 128 | except FileNotFoundError: 129 | pass 130 | 131 | 132 | data_store = JSONStorage( 133 | config["files"]["emails"], 134 | config["files"]["invites"], 135 | config["files"]["user_template"], 136 | config["files"]["user_displayprefs"], 137 | config["files"]["user_configuration"], 138 | ) 139 | 140 | if config.getboolean("ui", "bs5"): 141 | css_file = "bs5-jf.css" 142 | log.debug("Using Bootstrap 5") 143 | else: 144 | css_file = "bs4-jf.css" 145 | 146 | 147 | with open(config_base_path, "r") as f: 148 | themes = json.load(f)["ui"]["theme"] 149 | 150 | theme_options = themes["options"] 151 | 152 | if "theme" not in config["ui"] or config["ui"]["theme"] not in theme_options: 153 | config["ui"]["theme"] = themes["value"] 154 | 155 | if config.getboolean("ui", "bs5"): 156 | num = 5 157 | else: 158 | num = 4 159 | 160 | current_theme = config["ui"]["theme"] 161 | 162 | if "Bootstrap" in current_theme: 163 | css_file = f"bs{num}.css" 164 | elif "Jellyfin" in current_theme: 165 | css_file = f"bs{num}-jf.css" 166 | elif "Custom" in current_theme and "custom_css" in config["files"]: 167 | if config["files"]["custom_css"] != "": 168 | try: 169 | css_path = Path(config["files"]["custom_css"]) 170 | shutil.copy(css_path, (local_dir / "static" / css_path.name)) 171 | log.debug(f'Loaded custom CSS "{css_path.name}"') 172 | css_file = css_path.name 173 | except FileNotFoundError: 174 | log.error( 175 | f'Custom CSS {config["files"]["custom_css"]} not found, using default.' 176 | ) 177 | 178 | 179 | def resp(success=True, code=500): 180 | if success: 181 | r = jsonify({"success": True}) 182 | if code == 500: 183 | r.status_code = 200 184 | else: 185 | r.status_code = code 186 | else: 187 | r = jsonify({"success": False}) 188 | r.status_code = code 189 | return r 190 | 191 | app = Flask(__name__, root_path=str(local_dir)) 192 | 193 | def main(): 194 | if args.install: 195 | executable = sys.argv[0] 196 | print(f'Assuming executable path "{executable}".') 197 | options = ["systemd"] 198 | for i, opt in enumerate(options): 199 | print(f"{i+1}: {opt}") 200 | success = False 201 | while not success: 202 | try: 203 | method = options[int(input(">: ")) - 1] 204 | success = True 205 | except IndexError: 206 | pass 207 | if method == "systemd": 208 | with open(local_dir / "services" / "jf-accounts.service", "r") as f: 209 | data = f.read() 210 | data = data.replace("{executable}", executable) 211 | service_path = str(Path("jf-accounts.service").resolve()) 212 | with open(service_path, "w") as f: 213 | f.write(data) 214 | print(f"service written to the current directory\n({service_path}).") 215 | print("Place this in the appropriate directory, and reload daemons.") 216 | elif args.get_defaults: 217 | import json 218 | from jellyfin_accounts.jf_api import Jellyfin 219 | 220 | jf = Jellyfin( 221 | config["jellyfin"]["server"], 222 | config["jellyfin"]["client"], 223 | config["jellyfin"]["version"], 224 | config["jellyfin"]["device"], 225 | config["jellyfin"]["device_id"], 226 | ) 227 | print("NOTE: This can now be done through the web ui.") 228 | print( 229 | """ 230 | This tool lets you grab various settings from a user, 231 | so that they can be applied every time a new account is 232 | created. """ 233 | ) 234 | print("Step 1: User Policy.") 235 | print( 236 | """ 237 | A user policy stores a users permissions (e.g access rights and 238 | most of the other settings in the 'Profile' and 'Access' tabs 239 | of a user). """ 240 | ) 241 | success = False 242 | msg = "Get public users only or all users? (requires auth) [public/all]: " 243 | public = False 244 | while not success: 245 | choice = input(msg) 246 | if choice == "public": 247 | public = True 248 | print("Make sure the user is publicly visible!") 249 | success = True 250 | elif choice == "all": 251 | jf.authenticate( 252 | config["jellyfin"]["username"], config["jellyfin"]["password"] 253 | ) 254 | public = False 255 | success = True 256 | users = jf.getUsers(public=public) 257 | for index, user in enumerate(users): 258 | print(f'{index+1}) {user["Name"]}') 259 | success = False 260 | while not success: 261 | try: 262 | user_index = int(input(">: ")) - 1 263 | policy = users[user_index]["Policy"] 264 | success = True 265 | except (ValueError, IndexError): 266 | pass 267 | data_store.user_template = policy 268 | print(f'Policy written to "{config["files"]["user_template"]}".') 269 | print("In future, this policy will be copied to all new users.") 270 | print("Step 2: Homescreen Layout") 271 | print( 272 | """ 273 | You may want to customize the default layout of a new user's 274 | home screen. These settings can be applied to an account through 275 | the 'Home' section in a user's settings. """ 276 | ) 277 | success = False 278 | while not success: 279 | choice = input("Grab the chosen user's homescreen layout? [y/n]: ") 280 | if choice.lower() == "y": 281 | user_id = users[user_index]["Id"] 282 | configuration = users[user_index]["Configuration"] 283 | display_prefs = jf.getDisplayPreferences(user_id) 284 | data_store.user_configuration = configuration 285 | print( 286 | f'Configuration written to "{config["files"]["user_configuration"]}".' 287 | ) 288 | data_store.user_displayprefs = display_prefs 289 | print( 290 | f'Display Prefs written to "{config["files"]["user_displayprefs"]}".' 291 | ) 292 | success = True 293 | elif choice.lower() == "n": 294 | success = True 295 | 296 | else: 297 | app.config["DEBUG"] = config.getboolean("ui", "debug") 298 | app.config["SECRET_KEY"] = secrets.token_urlsafe(16) 299 | app.config["JSON_SORT_KEYS"] = False 300 | 301 | from waitress import serve 302 | 303 | if first_run: 304 | 305 | def signal_handler(sig, frame): 306 | print("Quitting...") 307 | sys.exit(0) 308 | 309 | signal.signal(signal.SIGINT, signal_handler) 310 | signal.signal(signal.SIGTERM, signal_handler) 311 | import jellyfin_accounts.setup 312 | 313 | host = config["ui"]["host"] 314 | port = config["ui"]["port"] 315 | log.info("Starting web UI for first run setup...") 316 | serve(app, host=host, port=port) 317 | else: 318 | import jellyfin_accounts.web_api 319 | import jellyfin_accounts.web 320 | import jellyfin_accounts.invite_daemon 321 | 322 | host = config["ui"]["host"] 323 | port = config["ui"]["port"] 324 | log.info(f"Starting web UI on {host}:{port}") 325 | if config.getboolean("password_resets", "enabled"): 326 | 327 | def start_pwr(): 328 | import jellyfin_accounts.pw_reset 329 | 330 | jellyfin_accounts.pw_reset.start() 331 | 332 | pwr = threading.Thread(target=start_pwr, daemon=True) 333 | log.info("Starting password reset thread") 334 | pwr.start() 335 | 336 | def signal_handler(sig, frame): 337 | print("Quitting...") 338 | if config.getboolean("notifications", "enabled"): 339 | jellyfin_accounts.invite_daemon.inviteDaemon.stop() 340 | sys.exit(0) 341 | 342 | signal.signal(signal.SIGINT, signal_handler) 343 | signal.signal(signal.SIGTERM, signal_handler) 344 | serve(app, host=host, port=int(port)) 345 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/templates/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 49 | {% if not bs5 %} 50 | 51 | {% endif %} 52 | 53 | {% if bs5 %} 54 | 55 | {% else %} 56 | 57 | {% endif %} 58 | 59 | 120 | Admin 121 | 122 | 123 | 146 | 175 | 194 | 217 | 234 | 246 |
247 |

248 | Accounts admin 249 |

250 |
251 | 254 |
255 |
256 |
Current Invites
257 |
    258 |
259 |
260 |
261 |
262 |
Generate Invite
263 |
264 |
265 |
266 |
267 |
268 | 269 | 271 |
272 |
273 | 274 | 276 |
277 |
278 | 279 | 281 |
282 |
283 |
284 |
285 | 288 |
289 |
290 | 291 |
292 | 293 |
294 |
295 |
296 | 297 | 300 | 301 |
302 | {% if email_enabled %} 303 |
304 | 305 |
306 |
307 | 308 |
309 | 310 |
311 |
312 | {% endif %} 313 |
314 |
315 |
316 |
317 |
318 | 321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |

{{ contactMessage }}

330 |
331 |
332 | 333 | 334 | 335 | 336 | -------------------------------------------------------------------------------- /jellyfin_accounts/web_api.py: -------------------------------------------------------------------------------- 1 | # A bit of a mess, but mostly does API endpoints and a couple compatability fixes 2 | from flask import request, jsonify 3 | from jellyfin_accounts.jf_api import Jellyfin 4 | import json 5 | import datetime 6 | import secrets 7 | import time 8 | import threading 9 | import os 10 | import sys 11 | import psutil 12 | from jellyfin_accounts import ( 13 | config, 14 | config_path, 15 | app, 16 | g, 17 | data_store, 18 | resp, 19 | configparser, 20 | config_base_path, 21 | ) 22 | from jellyfin_accounts.email import Mailgun, Smtp 23 | from jellyfin_accounts import web_log as log 24 | from jellyfin_accounts.validate_password import PasswordValidator 25 | 26 | 27 | def format_datetime(dt): 28 | result = dt.strftime(config["email"]["date_format"]) 29 | if config.getboolean("email", "use_24h"): 30 | result += f' {dt.strftime("%H:%M")}' 31 | else: 32 | result += f' {dt.strftime("%I:%M %p")}' 33 | return result 34 | 35 | 36 | def checkInvite(code, used=False, username=None): 37 | current_time = datetime.datetime.now() 38 | invites = dict(data_store.invites) 39 | match = False 40 | for invite in invites: 41 | if ( 42 | "remaining-uses" not in invites[invite] 43 | and "no-limit" not in invites[invite] 44 | ): 45 | invites[invite]["remaining-uses"] = 1 46 | expiry = datetime.datetime.strptime( 47 | invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f" 48 | ) 49 | if current_time >= expiry or ( 50 | "no-limit" not in invites[invite] and invites[invite]["remaining-uses"] < 1 51 | ): 52 | log.debug(f"Housekeeping: Deleting expired invite {invite}") 53 | if ( 54 | config.getboolean("notifications", "enabled") 55 | and "notify" in invites[invite] 56 | ): 57 | for address in invites[invite]["notify"]: 58 | if "notify-expiry" in invites[invite]["notify"][address]: 59 | if invites[invite]["notify"][address]["notify-expiry"]: 60 | method = config["email"]["method"] 61 | if method == "mailgun": 62 | email = Mailgun(address) 63 | elif method == "smtp": 64 | email = Smtp(address) 65 | if email.construct_expiry( 66 | {"code": invite, "expiry": expiry} 67 | ): 68 | threading.Thread(target=email.send).start() 69 | del data_store.invites[invite] 70 | elif invite == code: 71 | match = True 72 | if used: 73 | delete = False 74 | inv = dict(data_store.invites[code]) 75 | if "used-by" not in inv: 76 | inv["used-by"] = [] 77 | if "remaining-uses" in inv: 78 | if inv["remaining-uses"] == 1: 79 | delete = True 80 | del data_store.invites[code] 81 | elif "no-limit" not in invites[invite]: 82 | inv["remaining-uses"] -= 1 83 | inv["used-by"].append([username, format_datetime(current_time)]) 84 | if not delete: 85 | data_store.invites[code] = inv 86 | return match 87 | 88 | 89 | jf = Jellyfin( 90 | config["jellyfin"]["server"], 91 | config["jellyfin"]["client"], 92 | config["jellyfin"]["version"], 93 | config["jellyfin"]["device"], 94 | config["jellyfin"]["device_id"], 95 | ) 96 | 97 | from jellyfin_accounts.login import auth 98 | 99 | jf_address = config["jellyfin"]["server"] 100 | success = False 101 | for i in range(3): 102 | try: 103 | jf.authenticate(config["jellyfin"]["username"], config["jellyfin"]["password"]) 104 | success = True 105 | log.info(f"Successfully authenticated with {jf_address}") 106 | break 107 | except Jellyfin.AuthenticationError: 108 | log.error(f"Failed to authenticate with {jf_address}, Retrying...") 109 | time.sleep(5) 110 | 111 | if not success: 112 | log.error("Could not authenticate after 3 tries.") 113 | exit() 114 | 115 | # Temporary fixes below. 116 | 117 | 118 | def switchToIds(): 119 | try: 120 | with open(config["files"]["emails"], "r") as f: 121 | emails = json.load(f) 122 | except (FileNotFoundError, json.decoder.JSONDecodeError): 123 | emails = {} 124 | users = jf.getUsers(public=False) 125 | new_emails = {} 126 | match = False 127 | for key in emails: 128 | for user in users: 129 | if user["Name"] == key: 130 | match = True 131 | new_emails[user["Id"]] = emails[key] 132 | elif user["Id"] == key: 133 | new_emails[user["Id"]] = emails[key] 134 | if match: 135 | from pathlib import Path 136 | 137 | email_file = Path(config["files"]["emails"]).name 138 | log.info( 139 | ( 140 | f"{email_file} modified to use userID instead of " 141 | + "usernames. These will be used in future." 142 | ) 143 | ) 144 | emails = new_emails 145 | with open(config["files"]["emails"], "w") as f: 146 | f.write(json.dumps(emails, indent=4)) 147 | 148 | 149 | # Temporary, switches emails.json over from using Usernames to User IDs. 150 | switchToIds() 151 | 152 | 153 | from packaging import version 154 | 155 | if ( 156 | version.parse(jf.info["Version"]) >= version.parse("10.6.0") 157 | and bool(data_store.user_template) is not False 158 | ): 159 | if ( 160 | data_store.user_template["AuthenticationProviderId"] 161 | == "Emby.Server.Implementations.Library.DefaultAuthenticationProvider" 162 | ): 163 | log.info("Updating user_template for Jellyfin >= 10.6.0") 164 | data_store.user_template[ 165 | "AuthenticationProviderId" 166 | ] = "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider" 167 | if ( 168 | data_store.user_template["PasswordResetProviderId"] 169 | == "Emby.Server.Implementations.Library.DefaultPasswordResetProvider" 170 | ): 171 | data_store.user_template[ 172 | "PasswordResetProviderId" 173 | ] = "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider" 174 | 175 | 176 | def validator(): 177 | if config.getboolean("password_validation", "enabled"): 178 | return PasswordValidator( 179 | config["password_validation"]["min_length"], 180 | config["password_validation"]["upper"], 181 | config["password_validation"]["lower"], 182 | config["password_validation"]["number"], 183 | config["password_validation"]["special"], 184 | ) 185 | return PasswordValidator(0, 0, 0, 0, 0) 186 | 187 | 188 | @app.route("/newUser", methods=["POST"]) 189 | def newUser(): 190 | data = request.get_json() 191 | log.debug("Attempted newUser") 192 | if checkInvite(data["code"]): 193 | validation = validator().validate(data["password"]) 194 | valid = True 195 | for criterion in validation: 196 | if validation[criterion] is False: 197 | valid = False 198 | if valid: 199 | log.debug("User password valid") 200 | try: 201 | user = jf.newUser(data["username"], data["password"]) 202 | except Jellyfin.UserExistsError: 203 | error = f'User already exists named {data["username"]}' 204 | log.debug(error) 205 | return jsonify({"error": error}) 206 | except: 207 | return jsonify({"error": "Unknown error"}) 208 | invites = dict(data_store.invites) 209 | checkInvite(data["code"], used=True, username=data["username"]) 210 | if ( 211 | config.getboolean("notifications", "enabled") 212 | and "notify" in invites[data["code"]] 213 | ): 214 | for address in invites[data["code"]]["notify"]: 215 | if "notify-creation" in invites[data["code"]]["notify"][address]: 216 | if invites[data["code"]]["notify"][address]["notify-creation"]: 217 | method = config["email"]["method"] 218 | if method == "mailgun": 219 | email = Mailgun(address) 220 | elif method == "smtp": 221 | email = Smtp(address) 222 | if email.construct_created( 223 | { 224 | "code": data["code"], 225 | "username": data["username"], 226 | "created": datetime.datetime.now(), 227 | } 228 | ): 229 | threading.Thread(target=email.send).start() 230 | if user.status_code == 200: 231 | try: 232 | policy = data_store.user_template 233 | if policy != {}: 234 | jf.setPolicy(user.json()["Id"], policy) 235 | else: 236 | log.debug("user policy was blank") 237 | except: 238 | log.error("Failed to set new user policy") 239 | try: 240 | configuration = data_store.user_configuration 241 | displayprefs = data_store.user_displayprefs 242 | if configuration != {} and displayprefs != {}: 243 | if jf.setConfiguration(user.json()["Id"], configuration): 244 | jf.setDisplayPreferences(user.json()["Id"], displayprefs) 245 | log.debug("Set homescreen layout.") 246 | else: 247 | log.debug("user configuration and/or displayprefs were blank") 248 | except: 249 | log.error("Failed to set new user homescreen layout") 250 | if config.getboolean("password_resets", "enabled"): 251 | data_store.emails[user.json()["Id"]] = data["email"] 252 | log.debug("Email address stored") 253 | log.info("New user created") 254 | else: 255 | log.error(f"New user creation failed: {user.status_code}") 256 | return resp(False) 257 | else: 258 | log.debug("User password invalid") 259 | return jsonify(validation) 260 | else: 261 | log.debug("Attempted newUser unauthorized") 262 | return resp(False, code=401) 263 | 264 | 265 | @app.route("/generateInvite", methods=["POST"]) 266 | @auth.login_required 267 | def generateInvite(): 268 | current_time = datetime.datetime.now() 269 | data = request.get_json() 270 | delta = datetime.timedelta( 271 | days=int(data["days"]), hours=int(data["hours"]), minutes=int(data["minutes"]) 272 | ) 273 | invite_code = secrets.token_urlsafe(16) 274 | invite = {} 275 | invite["created"] = format_datetime(current_time) 276 | if data["multiple-uses"]: 277 | if data["no-limit"]: 278 | invite["no-limit"] = True 279 | else: 280 | invite["remaining-uses"] = int(data["remaining-uses"]) 281 | else: 282 | invite["remaining-uses"] = 1 283 | log.debug(f"Creating new invite: {invite_code}") 284 | valid_till = current_time + delta 285 | invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f") 286 | if "email" in data and config.getboolean("invite_emails", "enabled"): 287 | address = data["email"] 288 | invite["email"] = address 289 | log.info(f"Sending invite to {address}") 290 | method = config["email"]["method"] 291 | if method == "mailgun": 292 | from jellyfin_accounts.email import Mailgun 293 | 294 | email = Mailgun(address) 295 | elif method == "smtp": 296 | from jellyfin_accounts.email import Smtp 297 | 298 | email = Smtp(address) 299 | email.construct_invite({"expiry": valid_till, "code": invite_code}) 300 | response = email.send() 301 | if response is False or type(response) != bool: 302 | invite["email"] = f"Failed to send to {address}" 303 | if config.getboolean("notifications", "enabled"): 304 | if "notify-creation" in data: 305 | invite["notify-creation"] = data["notify-creation"] 306 | if "notify-expiry" in data: 307 | invite["notify-expiry"] = data["notify-expiry"] 308 | data_store.invites[invite_code] = invite 309 | log.info(f"New invite created: {invite_code}") 310 | return resp() 311 | 312 | 313 | @app.route("/getInvites", methods=["GET"]) 314 | @auth.login_required 315 | def getInvites(): 316 | log.debug("Invites requested") 317 | current_time = datetime.datetime.now() 318 | invites = dict(data_store.invites) 319 | for code in invites: 320 | checkInvite(code) 321 | invites = dict(data_store.invites) 322 | response = {"invites": []} 323 | for code in invites: 324 | expiry = datetime.datetime.strptime( 325 | invites[code]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f" 326 | ) 327 | valid_for = expiry - current_time 328 | invite = { 329 | "code": code, 330 | "days": valid_for.days, 331 | "hours": valid_for.seconds // 3600, 332 | "minutes": (valid_for.seconds // 60) % 60, 333 | } 334 | if "created" in invites[code]: 335 | invite["created"] = invites[code]["created"] 336 | if "used-by" in invites[code]: 337 | invite["used-by"] = invites[code]["used-by"] 338 | if "no-limit" in invites[code]: 339 | invite["no-limit"] = invites[code]["no-limit"] 340 | if "remaining-uses" in invites[code]: 341 | invite["remaining-uses"] = invites[code]["remaining-uses"] 342 | else: 343 | invite["remaining-uses"] = 1 344 | if "email" in invites[code]: 345 | invite["email"] = invites[code]["email"] 346 | if "notify" in invites[code]: 347 | if config.getboolean("ui", "jellyfin_login"): 348 | address = data_store.emails[g.user.id] 349 | else: 350 | address = config["ui"]["email"] 351 | if address in invites[code]["notify"]: 352 | if "notify-expiry" in invites[code]["notify"][address]: 353 | invite["notify-expiry"] = invites[code]["notify"][address][ 354 | "notify-expiry" 355 | ] 356 | if "notify-creation" in invites[code]["notify"][address]: 357 | invite["notify-creation"] = invites[code]["notify"][address][ 358 | "notify-creation" 359 | ] 360 | response["invites"].append(invite) 361 | return jsonify(response) 362 | 363 | 364 | @app.route("/deleteInvite", methods=["POST"]) 365 | @auth.login_required 366 | def deleteInvite(): 367 | code = request.get_json()["code"] 368 | invites = dict(data_store.invites) 369 | if code in invites: 370 | del data_store.invites[code] 371 | log.info(f"Invite deleted: {code}") 372 | return resp() 373 | 374 | 375 | @app.route("/getToken") 376 | @auth.login_required 377 | def get_token(): 378 | token = g.user.generate_token() 379 | return jsonify({"token": token.decode("ascii")}) 380 | 381 | 382 | @app.route("/getUsers", methods=["GET"]) 383 | @auth.login_required 384 | def getUsers(): 385 | log.debug("User and email list requested") 386 | response = {"users": []} 387 | users = jf.getUsers(public=False) 388 | emails = data_store.emails 389 | for user in users: 390 | entry = {"name": user["Name"]} 391 | if user["Id"] in emails: 392 | entry["email"] = emails[user["Id"]] 393 | response["users"].append(entry) 394 | return jsonify(response) 395 | 396 | 397 | @app.route("/modifyUsers", methods=["POST"]) 398 | @auth.login_required 399 | def modifyUsers(): 400 | data = request.get_json() 401 | log.debug("Email list modification requested") 402 | for key in data: 403 | uid = jf.getUsers(key, public=False)["Id"] 404 | data_store.emails[uid] = data[key] 405 | log.debug(f'Email for user "{key}" modified') 406 | return resp() 407 | 408 | 409 | @app.route("/setDefaults", methods=["POST"]) 410 | @auth.login_required 411 | def setDefaults(): 412 | data = request.get_json() 413 | username = data["username"] 414 | log.debug(f"Storing default settings from user {username}") 415 | try: 416 | user = jf.getUsers(username=username, public=False) 417 | except Jellyfin.UserNotFoundError: 418 | log.error(f"Storing defaults failed: Couldn't find user {username}") 419 | return resp(False) 420 | uid = user["Id"] 421 | policy = user["Policy"] 422 | data_store.user_template = policy 423 | if data["homescreen"]: 424 | configuration = user["Configuration"] 425 | try: 426 | displayprefs = jf.getDisplayPreferences(uid) 427 | data_store.user_configuration = configuration 428 | data_store.user_displayprefs = displayprefs 429 | except: 430 | log.error("Storing defaults failed: " + "couldn't store homescreen layout") 431 | return resp(False) 432 | return resp() 433 | 434 | 435 | @app.route("/modifyConfig", methods=["POST"]) 436 | @auth.login_required 437 | def modifyConfig(): 438 | global config 439 | log.info("Config modification requested") 440 | data = request.get_json() 441 | temp_config = configparser.RawConfigParser( 442 | comment_prefixes="/", allow_no_value=True 443 | ) 444 | temp_config.read(str(config_path.resolve())) 445 | for section in data: 446 | if section in temp_config and 'restart-program' not in section: 447 | for item in data[section]: 448 | temp_config[section][item] = data[section][item] 449 | data[section][item] = True 450 | log.debug(f"{section}/{item} modified") 451 | with open(config_path, "w") as config_file: 452 | temp_config.write(config_file) 453 | config.trigger_reload() 454 | log.info("Config written.") 455 | if 'restart-program' in data: 456 | if data['restart-program']: 457 | log.info('Restarting...') 458 | try: 459 | proc = psutil.Process(os.getpid()) 460 | for handler in proc.open_files() + proc.connections(): 461 | os.close(handler.fd) 462 | except Exception as e: 463 | log.error(f'Failed restart: {type(e).__name__}') 464 | python = sys.executable 465 | os.execl(python, python, *sys.argv) 466 | return resp() 467 | 468 | 469 | @app.route("/getConfig", methods=["GET"]) 470 | @auth.login_required 471 | def getConfig(): 472 | log.debug("Config requested") 473 | with open(config_base_path, "r") as f: 474 | config_base = json.load(f) 475 | # config.read(config_path) 476 | response_config = config_base 477 | for section in config_base: 478 | for entry in config_base[section]: 479 | if entry in config[section]: 480 | response_config[section][entry]["value"] = config[section][entry] 481 | return jsonify(response_config), 200 482 | 483 | 484 | @app.route("/setNotify", methods=["POST"]) 485 | @auth.login_required 486 | def setNotify(): 487 | data = request.get_json() 488 | change = False 489 | for code in data: 490 | for key in data[code]: 491 | if key in ["notify-expiry", "notify-creation"]: 492 | inv = data_store.invites[code] 493 | if config.getboolean("ui", "jellyfin_login"): 494 | address = data_store.emails[g.user.id] 495 | else: 496 | address = config["ui"]["email"] 497 | if "notify" not in inv: 498 | inv["notify"] = {} 499 | if address not in inv["notify"]: 500 | inv["notify"][address] = {} 501 | inv["notify"][address][key] = data[code][key] 502 | log.debug(f"{code}: Notification settings changed") 503 | change = True 504 | if change: 505 | data_store.invites[code] = inv 506 | return resp() 507 | return resp(success=False) 508 | -------------------------------------------------------------------------------- /jellyfin_accounts/data/config-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "jellyfin": { 3 | "meta": { 4 | "name": "Jellyfin", 5 | "description": "Settings for connecting to Jellyfin" 6 | }, 7 | "username": { 8 | "name": "Jellyfin Username", 9 | "required": true, 10 | "requires_restart": true, 11 | "type": "text", 12 | "value": "username", 13 | "description": "It is recommended to create a limited admin account for this program." 14 | }, 15 | "password": { 16 | "name": "Jellyfin Password", 17 | "required": true, 18 | "requires_restart": true, 19 | "type": "password", 20 | "value": "password" 21 | }, 22 | "server": { 23 | "name": "Server address", 24 | "required": true, 25 | "requires_restart": true, 26 | "type": "text", 27 | "value": "http://jellyfin.local:8096", 28 | "description": "Jellyfin server address. Can be public, or local for security purposes." 29 | }, 30 | "public_server": { 31 | "name": "Public address", 32 | "required": false, 33 | "requires_restart": false, 34 | "type": "text", 35 | "value": "https://jellyf.in:443", 36 | "description": "Publicly accessible Jellyfin address for invite form. Leave blank to reuse the above address." 37 | }, 38 | "client": { 39 | "name": "Client Name", 40 | "required": true, 41 | "requires_restart": true, 42 | "type": "text", 43 | "value": "jf-accounts", 44 | "description": "This and below settings will show on the Jellyfin dashboard when the program connects. You may as well leave them alone." 45 | }, 46 | "version": { 47 | "name": "Version Number", 48 | "required": true, 49 | "requires_restart": true, 50 | "type": "text", 51 | "value": "{version}" 52 | }, 53 | "device": { 54 | "name": "Device Name", 55 | "required": true, 56 | "requires_restart": true, 57 | "type": "text", 58 | "value": "jf-accounts" 59 | }, 60 | "device_id": { 61 | "name": "Device ID", 62 | "required": true, 63 | "requires_restart": true, 64 | "type": "text", 65 | "value": "jf-accounts-{version}" 66 | } 67 | }, 68 | "ui": { 69 | "meta": { 70 | "name": "General", 71 | "description": "Settings related to the UI and program functionality." 72 | }, 73 | "theme": { 74 | "name": "Default Look", 75 | "required": false, 76 | "requires_restart": true, 77 | "type": "select", 78 | "options": [ 79 | "Bootstrap (Light)", 80 | "Jellyfin (Dark)", 81 | "Custom CSS" 82 | ], 83 | "value": "Jellyfin (Dark)", 84 | "description": "Default appearance for all users." 85 | }, 86 | "host": { 87 | "name": "Address", 88 | "required": true, 89 | "requires_restart": true, 90 | "type": "text", 91 | "value": "0.0.0.0", 92 | "description": "Set 0.0.0.0 to run on localhost" 93 | }, 94 | "port": { 95 | "name": "Port", 96 | "required": true, 97 | "requires_restart": true, 98 | "type": "number", 99 | "value": 8056 100 | }, 101 | "jellyfin_login": { 102 | "name": "Use Jellyfin for authentication", 103 | "required": false, 104 | "requires_restart": true, 105 | "type": "bool", 106 | "value": true, 107 | "description": "Enable this to use Jellyfin users instead of the below username and pw." 108 | }, 109 | "admin_only": { 110 | "name": "Allow admin users only", 111 | "required": false, 112 | "requires_restart": true, 113 | "depends_true": "jellyfin_login", 114 | "type": "bool", 115 | "value": true, 116 | "description": "Allows only admin users on Jellyfin to access the admin page." 117 | }, 118 | "username": { 119 | "name": "Web Username", 120 | "required": true, 121 | "requires_restart": true, 122 | "depends_false": "jellyfin_login", 123 | "type": "text", 124 | "value": "your username", 125 | "description": "Username for admin page (Leave blank if using jellyfin_login)" 126 | }, 127 | "password": { 128 | "name": "Web Password", 129 | "required": true, 130 | "requires_restart": true, 131 | "depends_false": "jellyfin_login", 132 | "type": "password", 133 | "value": "your password", 134 | "description": "Password for admin page (Leave blank if using jellyfin_login)" 135 | }, 136 | "email": { 137 | "name": "Admin email address", 138 | "required": false, 139 | "requires_restart": false, 140 | "depends_false": "jellyfin_login", 141 | "type": "text", 142 | "value": "example@example.com", 143 | "description": "Address to send notifications to (Leave blank if using jellyfin_login)" 144 | }, 145 | "debug": { 146 | "name": "Debug logging", 147 | "required": false, 148 | "requires_restart": true, 149 | "type": "bool", 150 | "value": false 151 | }, 152 | "contact_message": { 153 | "name": "Contact message", 154 | "required": false, 155 | "requires_restart": false, 156 | "type": "text", 157 | "value": "Need help? contact me.", 158 | "description": "Displayed at bottom of all pages except admin" 159 | }, 160 | "help_message": { 161 | "name": "Help message", 162 | "required": false, 163 | "requires_restart": false, 164 | "type": "text", 165 | "value": "Enter your details to create an account.", 166 | "description": "Displayed at top of invite form." 167 | }, 168 | "success_message": { 169 | "name": "Success message", 170 | "required": false, 171 | "requires_restart": false, 172 | "type": "text", 173 | "value": "Your account has been created. Click below to continue to Jellyfin.", 174 | "description": "Displayed when a user creates an account" 175 | }, 176 | "bs5": { 177 | "name": "Use Bootstrap 5", 178 | "required": false, 179 | "requires_restart": false, 180 | "type": "bool", 181 | "value": false, 182 | "description": "Use Bootstrap 5 (currently in alpha). This also removes the need for jQuery, so the page should load faster." 183 | } 184 | }, 185 | "password_validation": { 186 | "meta": { 187 | "name": "Password Validation", 188 | "description": "Password validation (minimum length, etc.)" 189 | }, 190 | "enabled": { 191 | "name": "Enabled", 192 | "required": false, 193 | "requires_restart": false, 194 | "type": "bool", 195 | "value": true 196 | }, 197 | "min_length": { 198 | "name": "Minimum Length", 199 | "requires_restart": false, 200 | "depends_true": "enabled", 201 | "type": "text", 202 | "value": "8" 203 | }, 204 | "upper": { 205 | "name": "Minimum uppercase characters", 206 | "requires_restart": false, 207 | "depends_true": "enabled", 208 | "type": "text", 209 | "value": "1" 210 | }, 211 | "lower": { 212 | "name": "Minimum lowercase characters", 213 | "requires_restart": false, 214 | "depends_true": "enabled", 215 | "type": "text", 216 | "value": "0" 217 | }, 218 | "number": { 219 | "name": "Minimum number count", 220 | "requires_restart": false, 221 | "depends_true": "enabled", 222 | "type": "text", 223 | "value": "1" 224 | }, 225 | "special": { 226 | "name": "Minimum number of special characters", 227 | "requires_restart": false, 228 | "depends_true": "enabled", 229 | "type": "text", 230 | "value": "0" 231 | } 232 | }, 233 | "email": { 234 | "meta": { 235 | "name": "Email", 236 | "description": "General email settings. Ignore if not using email features." 237 | }, 238 | "no_username": { 239 | "name": "Use email addresses as username", 240 | "required": false, 241 | "requires_restart": false, 242 | "depends_true": "method", 243 | "type": "bool", 244 | "value": false, 245 | "description": "Use email address from invite form as username on Jellyfin." 246 | }, 247 | "use_24h": { 248 | "name": "Use 24h time", 249 | "required": false, 250 | "requires_restart": false, 251 | "depends_true": "method", 252 | "type": "bool", 253 | "value": true 254 | }, 255 | "date_format": { 256 | "name": "Date format", 257 | "required": false, 258 | "requires_restart": false, 259 | "depends_true": "method", 260 | "type": "text", 261 | "value": "%d/%m/%y", 262 | "description": "Date format used in emails. Follows datetime.strftime format." 263 | }, 264 | "message": { 265 | "name": "Help message", 266 | "required": false, 267 | "requires_restart": false, 268 | "depends_true": "method", 269 | "type": "text", 270 | "value": "Need help? contact me.", 271 | "description": "Message displayed at bottom of emails." 272 | }, 273 | "method": { 274 | "name": "Email method", 275 | "required": false, 276 | "requires_restart": false, 277 | "type": "select", 278 | "options": [ 279 | "smtp", 280 | "mailgun" 281 | ], 282 | "value": "smtp", 283 | "description": "Method of sending email to use." 284 | }, 285 | "address": { 286 | "name": "Sent from (address)", 287 | "required": false, 288 | "requires_restart": false, 289 | "depends_true": "method", 290 | "type": "email", 291 | "value": "jellyfin@jellyf.in", 292 | "description": "Address to send emails from" 293 | }, 294 | "from": { 295 | "name": "Sent from (name)", 296 | "required": false, 297 | "requires_restart": false, 298 | "depends_true": "method", 299 | "type": "text", 300 | "value": "Jellyfin", 301 | "description": "The name of the sender" 302 | } 303 | }, 304 | "password_resets": { 305 | "meta": { 306 | "name": "Password Resets", 307 | "description": "Settings for the password reset handler." 308 | }, 309 | "enabled": { 310 | "name": "Enabled", 311 | "required": false, 312 | "requires_restart": true, 313 | "type": "bool", 314 | "value": true, 315 | "description": "Enable to store provided email addresses, monitor Jellyfin directory for pw-resets, and send reset pins" 316 | }, 317 | "watch_directory": { 318 | "name": "Jellyfin directory", 319 | "required": false, 320 | "requires_restart": true, 321 | "depends_true": "enabled", 322 | "type": "text", 323 | "value": "/path/to/jellyfin", 324 | "description": "Path to the folder Jellyfin puts password-reset files." 325 | }, 326 | "email_html": { 327 | "name": "Custom email (HTML)", 328 | "required": false, 329 | "requires_restart": false, 330 | "depends_true": "enabled", 331 | "type": "text", 332 | "value": "", 333 | "description": "Path to custom email html" 334 | }, 335 | "email_text": { 336 | "name": "Custom email (plaintext)", 337 | "required": false, 338 | "requires_restart": false, 339 | "depends_true": "enabled", 340 | "type": "text", 341 | "value": "", 342 | "description": "Path to custom email in plain text" 343 | }, 344 | "subject": { 345 | "name": "Email subject", 346 | "required": false, 347 | "requires_restart": false, 348 | "depends_true": "enabled", 349 | "type": "text", 350 | "value": "Password Reset - Jellyfin", 351 | "description": "Subject of password reset emails." 352 | } 353 | }, 354 | "invite_emails": { 355 | "meta": { 356 | "name": "Invite emails", 357 | "description": "Settings for sending invites directly to users." 358 | }, 359 | "enabled": { 360 | "name": "Enabled", 361 | "required": false, 362 | "requires_restart": false, 363 | "type": "bool", 364 | "value": true 365 | }, 366 | "email_html": { 367 | "name": "Custom email (HTML)", 368 | "required": false, 369 | "requires_restart": false, 370 | "depends_true": "enabled", 371 | "type": "text", 372 | "value": "", 373 | "description": "Path to custom email HTML" 374 | }, 375 | "email_text": { 376 | "name": "Custom email (plaintext)", 377 | "required": false, 378 | "requires_restart": false, 379 | "depends_true": "enabled", 380 | "type": "text", 381 | "value": "", 382 | "description": "Path to custom email in plain text" 383 | }, 384 | "subject": { 385 | "name": "Email subject", 386 | "required": true, 387 | "requires_restart": false, 388 | "depends_true": "enabled", 389 | "type": "text", 390 | "value": "Invite - Jellyfin", 391 | "description": "Subject of invite emails." 392 | }, 393 | "url_base": { 394 | "name": "URL Base", 395 | "required": true, 396 | "requires_restart": false, 397 | "depends_true": "enabled", 398 | "type": "text", 399 | "value": "http://accounts.jellyf.in:8056/invite", 400 | "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." 401 | } 402 | }, 403 | "notifications": { 404 | "meta": { 405 | "name": "Notifications", 406 | "description": "Notification related settings." 407 | }, 408 | "enabled": { 409 | "name": "Enabled", 410 | "required": "false", 411 | "requires_restart": true, 412 | "type": "bool", 413 | "value": true, 414 | "description": "Enabling adds optional toggles to invites to notify on expiry and user creation." 415 | }, 416 | "expiry_html": { 417 | "name": "Expiry email (HTML)", 418 | "required": false, 419 | "requires_restart": false, 420 | "depends_true": "enabled", 421 | "type": "text", 422 | "value": "", 423 | "description": "Path to expiry notification email HTML." 424 | }, 425 | "expiry_text": { 426 | "name": "Expiry email (Plaintext)", 427 | "required": false, 428 | "requires_restart": "false", 429 | "depends_true": "enabled", 430 | "type": "text", 431 | "value": "", 432 | "description": "Path to expiry notification email in plaintext." 433 | }, 434 | "created_html": { 435 | "name": "User created email (HTML)", 436 | "required": false, 437 | "requires_restart": false, 438 | "depends_true": "enabled", 439 | "type": "text", 440 | "value": "", 441 | "description": "Path to user creation notification email HTML." 442 | }, 443 | "created_text": { 444 | "name": "User created email (Plaintext)", 445 | "required": false, 446 | "requires_restart": false, 447 | "depends_true": "enabled", 448 | "type": "text", 449 | "value": "", 450 | "description": "Path to user creation notification email in plaintext." 451 | } 452 | }, 453 | "mailgun": { 454 | "meta": { 455 | "name": "Mailgun (Email)", 456 | "description": "Mailgun API connection settings" 457 | }, 458 | "api_url": { 459 | "name": "API URL", 460 | "required": false, 461 | "requires_restart": false, 462 | "type": "text", 463 | "value": "https://api.mailgun.net..." 464 | }, 465 | "api_key": { 466 | "name": "API Key", 467 | "required": false, 468 | "requires_restart": false, 469 | "type": "text", 470 | "value": "your api key" 471 | } 472 | }, 473 | "smtp": { 474 | "meta": { 475 | "name": "SMTP (Email)", 476 | "description": "SMTP Server connection settings." 477 | }, 478 | "encryption": { 479 | "name": "Encryption Method", 480 | "required": false, 481 | "requires_restart": false, 482 | "type": "select", 483 | "options": [ 484 | "ssl_tls", 485 | "starttls" 486 | ], 487 | "value": "starttls", 488 | "description": "Your email provider should provide different ports for each encryption method. Generally 465 for ssl_tls, 587 for starttls." 489 | }, 490 | "server": { 491 | "name": "Server address", 492 | "required": false, 493 | "requires_restart": false, 494 | "type": "text", 495 | "value": "smtp.jellyf.in", 496 | "description": "SMTP Server address." 497 | }, 498 | "port": { 499 | "name": "Port", 500 | "required": false, 501 | "requires_restart": false, 502 | "type": "number", 503 | "value": 465 504 | }, 505 | "password": { 506 | "name": "Password", 507 | "required": false, 508 | "requires_restart": false, 509 | "type": "password", 510 | "value": "smtp password" 511 | } 512 | }, 513 | "files": { 514 | "meta": { 515 | "name": "File Storage", 516 | "description": "Optional settings for changing storage locations." 517 | }, 518 | "invites": { 519 | "name": "Invite Storage", 520 | "required": false, 521 | "requires_restart": true, 522 | "type": "text", 523 | "value": "", 524 | "description": "Location of stored invites (json)." 525 | }, 526 | "emails": { 527 | "name": "Email Addresses", 528 | "required": false, 529 | "requires_restart": true, 530 | "type": "text", 531 | "value": "", 532 | "description": "Location of stored email addresses (json)." 533 | }, 534 | "user_template": { 535 | "name": "User Template", 536 | "required": false, 537 | "requires_restart": true, 538 | "type": "text", 539 | "value": "", 540 | "description": "Location of stored user policy template (json)." 541 | }, 542 | "user_configuration": { 543 | "name": "userConfiguration", 544 | "required": false, 545 | "requires_restart": true, 546 | "type": "text", 547 | "value": "", 548 | "description": "Location of stored user configuration template (used for setting homescreen layout) (json)" 549 | }, 550 | "user_displayprefs": { 551 | "name": "displayPreferences", 552 | "required": false, 553 | "requires_restart": true, 554 | "type": "text", 555 | "value": "", 556 | "description": "Location of stored displayPreferences template (also used for homescreen layout) (json)" 557 | }, 558 | "custom_css": { 559 | "name": "Custom CSS", 560 | "required": false, 561 | "requires_restart": true, 562 | "type": "text", 563 | "value": "", 564 | "description": "Location of custom bootstrap CSS." 565 | } 566 | } 567 | } 568 | --------------------------------------------------------------------------------