mirror of
https://github.com/hrfee/jellyfin-accounts.git
synced 2024-12-22 09:00:14 +00:00
Add pw reset support; add logging
This commit is contained in:
parent
2e22e5f840
commit
d22ba6133b
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,6 +1,9 @@
|
||||
__pycache__
|
||||
__pycache__/
|
||||
notes.md
|
||||
MANIFEST.in
|
||||
dist/
|
||||
build/
|
||||
test.txt
|
||||
data/node_modules/
|
||||
*.egg-info/
|
||||
pw-reset/
|
||||
|
68
README.md
68
README.md
@ -1,8 +1,13 @@
|
||||
# jellyfin-accounts
|
||||
A simple, web-based invite system for [Jellyfin](https://github.com/jellyfin/jellyfin).
|
||||
***New: Now capable of sending password reset emails!***
|
||||
|
||||
A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin).
|
||||
* Provides a web interface for creating invite codes
|
||||
* Sends out emails when a user requests a password reset
|
||||
* Uses a basic python jellyfin API client for communication with the server.
|
||||
* Uses [Flask](https://github.com/pallets/flask), [HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth), [itsdangerous](https://github.com/pallets/itsdangerous), and [Waitress](https://github.com/Pylons/waitress)
|
||||
* Frontend uses [Bootstrap](https://getbootstrap.com), [jQuery](https://jquery.com) and [jQuery-serialize-object](https://github.com/macek/jquery-serialize-object)
|
||||
* Password resets are handled using smtplib, requests, and [jinja](https://github.com/pallets/jinja)
|
||||
## Screenshots
|
||||
<p align="center">
|
||||
<img src="images/admin.png" width="45%"></img> <img src="images/create.png" width="45%"></img>
|
||||
@ -10,16 +15,23 @@ A simple, web-based invite system for [Jellyfin](https://github.com/jellyfin/jel
|
||||
|
||||
## Get it
|
||||
### Requirements
|
||||
|
||||
* This should work anywhere Python does, i've tried to not use anything OS-specific. Drop an issue if you encounter issues, of course.
|
||||
```
|
||||
* python >= 3.6
|
||||
* flask
|
||||
* flask_httpauth
|
||||
* jinja2
|
||||
* requests
|
||||
* itsdangerous
|
||||
* passlib
|
||||
* secrets
|
||||
* configparser
|
||||
* waitress
|
||||
|
||||
* pytz
|
||||
* dateutil
|
||||
* watchdog
|
||||
```
|
||||
### Install
|
||||
```
|
||||
git clone https://github.com/hrfee/jellyfin-accounts.git
|
||||
@ -47,16 +59,21 @@ optional arguments:
|
||||
```
|
||||
### Setup
|
||||
#### Policy template
|
||||
* You may want to restrict from accessing certain libraries (e.g 4K Movies), or display their account on the login screen by default. Jellyfin stores these settings as a user's policy.
|
||||
* You may want to restrict a user from accessing certain libraries (e.g 4K Movies), or display their account on the login screen by default. Jellyfin stores these settings as a user's policy.
|
||||
* Make a temporary account and change its settings, then run `jf-accounts --get_policy`. Choose your user, and the policy will be stored at the location you set in `user_template`, and used for all subsequent new accounts.
|
||||
#### Emails/Password Resets
|
||||
* When someone initiates forget password on Jellyfin, a file named `passwordreset*.json` is created in its configuration directory. This directory is monitored and when created, the program reads the username, expiry time and PIN, puts it into a template and sends it to whatever address is specified in `emails.json`.
|
||||
* **The default forget password popup references the `passwordreset*.json` file created. This is confusing for users, so a quick fix is to edit the `MessageForgotPasswordFileCreated` string in Jellyfin's language folder.**
|
||||
* Currently, jellyfin-accounts supports generic SSL/TLS secured SMTP, and the [mailgun](https://mailgun.com) REST API.
|
||||
* Email html is created using [mjml](https://mjml.io), and [jinja](https://github.com/pallets/jinja) templating is used. If you wish to create your own, ensure you use the same jinja expressions (`{{ pin }}`, etc.) as used in `data/email.mjml`, and also create a plain text version for legacy email clients.
|
||||
|
||||
### Configuration
|
||||
#### Configuration
|
||||
* Note: Make sure to put this behind a reverse proxy with HTTPS.
|
||||
|
||||
On first run, the default configuration is copied to `~/.jf-accounts/config.ini`.
|
||||
```
|
||||
; It is reccommended to create a limited admin account for this program.
|
||||
[jellyfin]
|
||||
; It is reccommended to create a limited admin account for this program.
|
||||
username = username
|
||||
password = password
|
||||
; Server will also be used in the invite form, so make sure it's publicly accessible.
|
||||
@ -72,23 +89,56 @@ port = 8056
|
||||
username = your username
|
||||
password = your password
|
||||
debug = false
|
||||
; Enable to store request email address and store. Useful for sending password reset emails.
|
||||
emails_enabled = false
|
||||
|
||||
; Displayed at the bottom of all pages except admin.
|
||||
; Displayed at the bottom of all pages except admin
|
||||
contact_message = Need help? contact me.
|
||||
; Displayed at top of form page.
|
||||
help_message = Enter your details to create an account.
|
||||
; Displayed when an account is created.
|
||||
success_message = Your account has been created. Click below to continue to Jellyfin.
|
||||
|
||||
[email]
|
||||
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
|
||||
enabled = true
|
||||
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
|
||||
watch_directory = /path/to/jellyfin
|
||||
use_24h = true
|
||||
; Date format follows datetime's strftime.
|
||||
date_format = %-d/%-m/%-y
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_template =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_plaintext =
|
||||
; Displayed at bottom of emails
|
||||
message = Need help? contact me.
|
||||
; Mail methods: mailgun, smtp
|
||||
method = mailgun
|
||||
; Subject of emails
|
||||
subject = Password Reset - Jellyfin
|
||||
; Address to send from
|
||||
address = jellyfin@jellyf.in
|
||||
; The name of the sender
|
||||
from = Jellyfin
|
||||
|
||||
[mailgun]
|
||||
|
||||
api_url = https://api.mailgun.net...
|
||||
api_key = your api key
|
||||
|
||||
[smtp]
|
||||
; Insecure SMTP hasn't been implemented, although I doubt many will need it.
|
||||
ssl = true
|
||||
server = smtp.jellyf.in
|
||||
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
|
||||
port = 465
|
||||
password = smtp password
|
||||
|
||||
[files]
|
||||
; When the below paths are left blank, files are stored in ~/.jf-accounts/.
|
||||
|
||||
; Path to store valid invites.
|
||||
invites =
|
||||
; Path to store emails in JSON
|
||||
; Path to store email addresses in JSON
|
||||
emails =
|
||||
; Path to the user policy template. Can be acquired with get-template.
|
||||
user_template =
|
||||
|
@ -15,25 +15,57 @@ port = 8056
|
||||
username = your username
|
||||
password = your password
|
||||
debug = false
|
||||
; Enable to store request email address and store. Useful for sending password reset emails.
|
||||
emails_enabled = false
|
||||
|
||||
; Displayed at the bottom of all pages except admin.
|
||||
; Displayed at the bottom of all pages except admin
|
||||
contact_message = Need help? contact me.
|
||||
; Displayed at top of form page.
|
||||
help_message = Enter your details to create an account.
|
||||
; Displayed when an account is created.
|
||||
success_message = Your account has been created. Click below to continue to Jellyfin.
|
||||
|
||||
[email]
|
||||
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
|
||||
enabled = true
|
||||
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
|
||||
watch_directory = /path/to/jellyfin
|
||||
use_24h = true
|
||||
; Date format follows datetime's strftime.
|
||||
date_format = %-d/%-m/%-y
|
||||
; Path to custom email html. If blank, uses the internal template.
|
||||
email_template =
|
||||
; Path to alternate plaintext email. If blank, uses the internal template.
|
||||
email_plaintext =
|
||||
; Displayed at bottom of emails
|
||||
message = Need help? contact me.
|
||||
; Mail methods: mailgun, smtp
|
||||
method = smtp
|
||||
; Subject of emails
|
||||
subject = Password Reset - Jellyfin
|
||||
; Address to send from
|
||||
address = jellyfin@jellyf.in
|
||||
; The name of the sender
|
||||
from = Jellyfin
|
||||
|
||||
[mailgun]
|
||||
|
||||
api_url = https://api.mailgun.net...
|
||||
api_key = your api key
|
||||
|
||||
[smtp]
|
||||
; Insecure SMTP hasn't been implemented, although I doubt many will need it.
|
||||
ssl = true
|
||||
server = smtp.jellyf.in
|
||||
; Uses SMTP_SSL, so make sure the port is for this, not starttls.
|
||||
port = 465
|
||||
password = smtp password
|
||||
|
||||
[files]
|
||||
; When the below paths are left blank, files are stored in ~/.jf-accounts/.
|
||||
|
||||
; Path to store valid invites.
|
||||
invites =
|
||||
; Path to store emails in JSON
|
||||
; Path to store emails addresses in JSON
|
||||
emails =
|
||||
; Path to the user policy template. Can be acquired with get-template.
|
||||
user_template =
|
||||
|
||||
|
||||
|
242
data/email.html
Normal file
242
data/email.html
Normal file
@ -0,0 +1,242 @@
|
||||
<!-- FILE: email.mjml -->
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-- -->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div style="">
|
||||
<!--[if mso | IE]>
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Noto Sans;font-size:15px;line-height:1;text-align:left;color:#000000;">
|
||||
<p>Hi {{ username }},</p>
|
||||
<p> Someone has recently requested a password reset on Jellyfin.</p>
|
||||
<p>If this was you, enter the below pin into the prompt.</p>
|
||||
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
|
||||
<p>If this wasn't you, please ignore this email.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
|
||||
<p style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;">
|
||||
{{ pin }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table
|
||||
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||
>
|
||||
<tr>
|
||||
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||
<![endif]-->
|
||||
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
|
||||
<tr>
|
||||
|
||||
<td
|
||||
class="" style="vertical-align:top;width:600px;"
|
||||
>
|
||||
<![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
34
data/email.mjml
Normal file
34
data/email.mjml
Normal file
@ -0,0 +1,34 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section background-color="#f0f0f0">
|
||||
<mj-column>
|
||||
<mj-text font-style="bold" font-size="20px">
|
||||
Jellyfin
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="15px" font-family="Noto Sans">
|
||||
<p>Hi {{ username }},</p>
|
||||
<p> Someone has recently requested a password reset on Jellyfin.</p>
|
||||
<p>If this was you, enter the below pin into the prompt.</p>
|
||||
<p>The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.</p>
|
||||
<p>If this wasn't you, please ignore this email.</p>
|
||||
</mj-text>
|
||||
<mj-button>{{ pin }}</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#f0f0f0">
|
||||
<mj-column>
|
||||
<mj-text font-style="italic" font-size="10px">
|
||||
{{ message }}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</body>
|
||||
</mjml>
|
||||
|
10
data/email.txt
Normal file
10
data/email.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Hi {{ username }},
|
||||
|
||||
Someone has recently requests a password reset on Jellyfin.
|
||||
If this was you, enter the below pin into the prompt.
|
||||
This code will expire on {{ expiry_date }}, at {{ expiry_time }} , which is in {{ expires_in }}.
|
||||
If this wasn't you, please ignore this email.
|
||||
|
||||
PIN: {{ pin }}
|
||||
|
||||
{{ message }}
|
@ -7,6 +7,7 @@ from itsdangerous import (TimedJSONWebSignatureSerializer
|
||||
from passlib.apps import custom_app_context as pwd_context
|
||||
import uuid
|
||||
from __main__ import config, app, g
|
||||
from __main__ import auth_log as log
|
||||
|
||||
|
||||
class Account():
|
||||
@ -44,11 +45,13 @@ def verify_password(username, password):
|
||||
if not user:
|
||||
if username == adminAccount.username and adminAccount.verify_password(password):
|
||||
g.user = adminAccount
|
||||
print(g)
|
||||
log.debug("HTTPAuth Allowed")
|
||||
return True
|
||||
else:
|
||||
log.debug("HTTPAuth Denied")
|
||||
return False
|
||||
g.user = adminAccount
|
||||
log.debug("HTTPAuth Allowed")
|
||||
return True
|
||||
|
||||
|
||||
|
194
jellyfin_accounts/pw_reset.py
Executable file
194
jellyfin_accounts/pw_reset.py
Executable file
@ -0,0 +1,194 @@
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
import datetime
|
||||
import pytz
|
||||
import requests
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from pathlib import Path
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from dateutil import parser as date_parser
|
||||
from jinja2 import Environment, FileSystemLoader, Template
|
||||
from __main__ import config
|
||||
from __main__ import email_log as log
|
||||
|
||||
|
||||
class Email():
|
||||
def __init__(self, address):
|
||||
self.address = address
|
||||
log.debug(f'{self.address}: Creating email')
|
||||
self.content = {}
|
||||
self.subject = config['email']['subject']
|
||||
log.debug(f'{self.address}: Using subject {self.subject}')
|
||||
self.from_address = config['email']['address']
|
||||
self.from_name = config['email']['from']
|
||||
log.debug((
|
||||
f'{self.address}: Sending from {self.from_address} ' +
|
||||
f'({self.from_name})'))
|
||||
|
||||
def construct(self, reset):
|
||||
log.debug(f'{self.address}: Constructing email content')
|
||||
try:
|
||||
expiry = date_parser.parse(reset['ExpirationDate'])
|
||||
expiry = expiry.replace(tzinfo=None)
|
||||
except:
|
||||
log.error(f"{self.address}: Couldn't parse expiry time")
|
||||
return False
|
||||
current_time = datetime.datetime.now()
|
||||
if expiry >= current_time:
|
||||
log.debug(f'{self.address}: Invite valid')
|
||||
date = expiry.strftime(config['email']['date_format'])
|
||||
if config.getboolean('email', 'use_24h'):
|
||||
log.debug(f'{self.address}: Using 24h time')
|
||||
time = expiry.strftime('%H:%M')
|
||||
else:
|
||||
log.debug(f'{self.address}: Using 12h time')
|
||||
time = expiry.strftime('%-I:%M %p')
|
||||
expiry_delta = (expiry - current_time).seconds
|
||||
expires_in = {'hours': expiry_delta//3600,
|
||||
'minutes': (expiry_delta//60) % 60}
|
||||
if expires_in['hours'] == 0:
|
||||
expires_in = f'{str(expires_in["minutes"])}m'
|
||||
else:
|
||||
expires_in = (f'{str(expires_in["hours"])}h ' +
|
||||
f'{str(expires_in["minutes"])}m')
|
||||
log.debug(f'{self.address}: Expires in {expires_in}')
|
||||
sp = Path(config['email']['email_template']) / '..'
|
||||
sp = str(sp.resolve()) + '/'
|
||||
templateLoader = FileSystemLoader(searchpath=sp)
|
||||
templateEnv = Environment(loader=templateLoader)
|
||||
file_text = Path(config['email']['email_plaintext']).name
|
||||
file_html = Path(config['email']['email_template']).name
|
||||
template = {}
|
||||
template['text'] = templateEnv.get_template(file_text)
|
||||
template['html'] = templateEnv.get_template(file_html)
|
||||
email_message = config['email']['message']
|
||||
for key in template:
|
||||
c = template[key].render(username=reset['UserName'],
|
||||
expiry_date=date,
|
||||
expiry_time=time,
|
||||
expires_in=expires_in,
|
||||
pin=reset['Pin'],
|
||||
message=email_message)
|
||||
self.content[key] = c
|
||||
log.info(f'{self.address}: {key} constructed')
|
||||
return True
|
||||
else:
|
||||
err = ((f"{self.address}: " +
|
||||
"Reset has reportedly already expired. " +
|
||||
"Ensure timezones are correctly configured."))
|
||||
log.error(err)
|
||||
return False
|
||||
|
||||
|
||||
class Mailgun(Email):
|
||||
def __init__(self, address):
|
||||
super().__init__(address)
|
||||
self.api_url = config['mailgun']['api_url']
|
||||
self.api_key = config['mailgun']['api_key']
|
||||
self.from_mg = f'{self.from_name} <{self.from_address}>'
|
||||
|
||||
def send(self):
|
||||
response = requests.post(self.api_url,
|
||||
auth=("api", self.api_key),
|
||||
data={"from": self.from_mg,
|
||||
"to": [self.address],
|
||||
"subject": self.subject,
|
||||
"text": self.content['text'],
|
||||
"html": self.content['html']})
|
||||
if response.ok:
|
||||
log.info(f'{self.address}: Sent via mailgun.')
|
||||
return True
|
||||
log.debug(f'{self.address}: Mailgun: {response.status_code}')
|
||||
return response
|
||||
|
||||
|
||||
class Smtp(Email):
|
||||
def __init__(self, address):
|
||||
super().__init__(address)
|
||||
self.server = config['smtp']['server']
|
||||
self.password = config['smtp']['password']
|
||||
try:
|
||||
self.port = int(config['smtp']['port'])
|
||||
except ValueError:
|
||||
self.port = 465
|
||||
log.debug(f'{self.address}: Defaulting to port {self.port}')
|
||||
self.context = ssl.create_default_context()
|
||||
|
||||
def send(self):
|
||||
message = MIMEMultipart("alternative")
|
||||
message["Subject"] = self.subject
|
||||
message["From"] = self.from_address
|
||||
message["To"] = self.address
|
||||
text = MIMEText(self.content['text'], 'plain')
|
||||
html = MIMEText(self.content['html'], 'html')
|
||||
message.attach(text)
|
||||
message.attach(html)
|
||||
try:
|
||||
with smtplib.SMTP_SSL(self.server,
|
||||
self.port,
|
||||
context=self.context) as server:
|
||||
server.login(self.from_address, self.password)
|
||||
server.sendmail(self.from_address,
|
||||
self.address,
|
||||
message.as_string())
|
||||
log.info(f'{self.address}: Sent via smtp')
|
||||
return True
|
||||
except Exception as e:
|
||||
err = f'{self.address}: Failed to send via smtp: '
|
||||
err += type(e).__name__
|
||||
log.error(err)
|
||||
|
||||
|
||||
class Watcher:
|
||||
def __init__(self, dir):
|
||||
self.observer = Observer()
|
||||
self.dir = str(dir)
|
||||
|
||||
def run(self):
|
||||
event_handler = Handler()
|
||||
self.observer.schedule(event_handler, self.dir, recursive=True)
|
||||
self.observer.start()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(5)
|
||||
except:
|
||||
self.observer.stop()
|
||||
log.info('Watchdog stopped')
|
||||
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
@staticmethod
|
||||
def on_any_event(event):
|
||||
if event.is_directory:
|
||||
return None
|
||||
elif (event.event_type == 'created' and
|
||||
'passwordreset' in event.src_path):
|
||||
with open(event.src_path, 'r') as f:
|
||||
reset = json.load(f)
|
||||
log.info(f'New password reset for {reset["UserName"]}')
|
||||
try:
|
||||
with open(config['files']['emails'], 'r') as f:
|
||||
emails = json.load(f)
|
||||
address = emails[reset['UserName']]
|
||||
method = config['email']['method']
|
||||
if method == 'mailgun':
|
||||
email = Mailgun(address)
|
||||
elif method == 'smtp':
|
||||
email = Smtp(address)
|
||||
if email.construct(reset):
|
||||
email.send()
|
||||
except (FileNotFoundError,
|
||||
json.decoder.JSONDecodeError,
|
||||
IndexError) as e:
|
||||
err = f'{address}: Failed: ' + type(e).__name__
|
||||
log.error(err)
|
||||
|
||||
def start():
|
||||
log.info(f'Monitoring {config["email"]["watch_directory"]}')
|
||||
w = Watcher(config['email']['watch_directory'])
|
||||
w.run()
|
@ -1,6 +1,7 @@
|
||||
from pathlib import Path
|
||||
from flask import Flask, send_from_directory, render_template
|
||||
from __main__ import config, app, g
|
||||
from __main__ import web_log as log
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@ -29,6 +30,7 @@ from jellyfin_accounts.web_api import checkInvite
|
||||
@app.route('/invite/<path:path>')
|
||||
def inviteProxy(path):
|
||||
if checkInvite(path):
|
||||
log.info(f'Invite {path} used to request form')
|
||||
return render_template('form.html',
|
||||
contactMessage=config['ui']['contact_message'],
|
||||
helpMessage=config['ui']['help_message'],
|
||||
@ -37,5 +39,6 @@ def inviteProxy(path):
|
||||
elif 'admin.html' not in path and 'admin.html' not in path:
|
||||
return app.send_static_file(path)
|
||||
else:
|
||||
log.debug('Attempted use of invalid invite')
|
||||
return render_template('invalidCode.html',
|
||||
contactMessage=config['ui']['contact_message'])
|
||||
|
@ -3,7 +3,9 @@ from jellyfin_accounts.jf_api import Jellyfin
|
||||
import json
|
||||
import datetime
|
||||
import secrets
|
||||
import time
|
||||
from __main__ import config, app, g
|
||||
from __main__ import web_log as log
|
||||
from jellyfin_accounts.login import auth
|
||||
|
||||
def resp(success=True, code=500):
|
||||
@ -28,6 +30,8 @@ def checkInvite(code, delete=False):
|
||||
expiry = datetime.datetime.strptime(i['valid_till'],
|
||||
'%Y-%m-%dT%H:%M:%S.%f')
|
||||
if current_time >= expiry:
|
||||
log.debug(('Housekeeping: Deleting old invite ' +
|
||||
invites['invites'][index]['code']))
|
||||
del invites['invites'][index]
|
||||
else:
|
||||
if i['code'] == code:
|
||||
@ -45,13 +49,26 @@ jf = Jellyfin(config['jellyfin']['server'],
|
||||
config['jellyfin']['device'],
|
||||
config['jellyfin']['device_id'])
|
||||
|
||||
jf.authenticate(config['jellyfin']['username'],
|
||||
config['jellyfin']['password'])
|
||||
attempts = 0
|
||||
while attempts != 3:
|
||||
try:
|
||||
jf.authenticate(config['jellyfin']['username'],
|
||||
config['jellyfin']['password'])
|
||||
log.info(('Successfully authenticated with ' +
|
||||
config['jellyfin']['server']))
|
||||
break
|
||||
except jellyfin_accounts.jf_api.AuthenticationError:
|
||||
attempts += 1
|
||||
log.error(('Failed to authenticate with ' +
|
||||
config['jellyfin']['server'] +
|
||||
'. Retrying...'))
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
@app.route('/newUser', methods=['GET', 'POST'])
|
||||
def newUser():
|
||||
data = request.get_json()
|
||||
log.debug('Attempted newUser')
|
||||
if checkInvite(data['code'], delete=True):
|
||||
user = jf.newUser(data['username'], data['password'])
|
||||
if user.status_code == 200:
|
||||
@ -60,8 +77,9 @@ def newUser():
|
||||
default_policy = json.load(f)
|
||||
jf.setPolicy(user.json()['Id'], default_policy)
|
||||
except:
|
||||
log.debug('setPolicy failed')
|
||||
pass
|
||||
if config['ui']['emails_enabled'] == 'true':
|
||||
if config.getboolean('email', 'enabled'):
|
||||
try:
|
||||
with open(config['files']['emails'], 'r') as f:
|
||||
emails = json.load(f)
|
||||
@ -70,10 +88,14 @@ def newUser():
|
||||
emails[data['username']] = data['email']
|
||||
with open(config['files']['emails'], 'w') as f:
|
||||
f.write(json.dumps(emails, indent=4))
|
||||
log.debug('Email address stored')
|
||||
log.info('New User created.')
|
||||
return resp()
|
||||
else:
|
||||
log.error(f'New user creation failed: {user.status_code}')
|
||||
return resp(False)
|
||||
else:
|
||||
log.debug('Attempted newUser unauthorized')
|
||||
return resp(False, code=401)
|
||||
|
||||
|
||||
@ -85,6 +107,7 @@ def generateInvite():
|
||||
delta = datetime.timedelta(hours=int(data['hours']),
|
||||
minutes=int(data['minutes']))
|
||||
invite = {'code': secrets.token_urlsafe(16)}
|
||||
log.debug(f'Creating new invite: {invite["code"]}')
|
||||
invite['valid_till'] = (current_time +
|
||||
delta).strftime('%Y-%m-%dT%H:%M:%S.%f')
|
||||
try:
|
||||
@ -95,12 +118,14 @@ def generateInvite():
|
||||
invites['invites'].append(invite)
|
||||
with open(config['files']['invites'], 'w') as f:
|
||||
f.write(json.dumps(invites, indent=4, default=str))
|
||||
log.info(f'New invite created: {invite["code"]}')
|
||||
return resp()
|
||||
|
||||
|
||||
@app.route('/getInvites', methods=['GET'])
|
||||
@auth.login_required
|
||||
def getInvites():
|
||||
log.debug('Invites requested')
|
||||
current_time = datetime.datetime.now()
|
||||
try:
|
||||
with open(config['files']['invites'], 'r') as f:
|
||||
@ -111,6 +136,8 @@ def getInvites():
|
||||
for index, i in enumerate(invites['invites']):
|
||||
expiry = datetime.datetime.strptime(i['valid_till'], '%Y-%m-%dT%H:%M:%S.%f')
|
||||
if current_time >= expiry:
|
||||
log.debug(('Housekeeping: Deleting old invite ' +
|
||||
invites['invites'][index]['code']))
|
||||
del invites['invites'][index]
|
||||
else:
|
||||
valid_for = expiry - current_time
|
||||
@ -137,6 +164,7 @@ def deleteInvite():
|
||||
del invites['invites'][index]
|
||||
with open(config['files']['invites'], 'w') as f:
|
||||
f.write(json.dumps(invites, indent=4, default=str))
|
||||
log.info(f'Invite deleted: {code}')
|
||||
return resp()
|
||||
|
||||
|
||||
@ -144,6 +172,7 @@ def deleteInvite():
|
||||
@auth.login_required
|
||||
def get_token():
|
||||
token = g.user.generate_token()
|
||||
log.debug('Token generated')
|
||||
return jsonify({'token': token.decode('ascii')})
|
||||
|
||||
|
||||
|
60
jf-accounts
60
jf-accounts
@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import secrets
|
||||
import configparser
|
||||
import shutil
|
||||
import argparse
|
||||
import logging
|
||||
import threading
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from flask import Flask, g
|
||||
|
||||
@ -48,18 +51,49 @@ if not data_dir.exists():
|
||||
else:
|
||||
config_path = data_dir / 'config.ini'
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config = configparser.RawConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
def create_log(name):
|
||||
log = logging.getLogger(name)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
if config.getboolean('ui', 'debug'):
|
||||
log.setLevel(logging.DEBUG)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
else:
|
||||
log.setLevel(logging.INFO)
|
||||
handler.setLevel(logging.INFO)
|
||||
fmt = ' %(name)s - %(levelname)s - %(message)s'
|
||||
format = logging.Formatter(fmt)
|
||||
handler.setFormatter(format)
|
||||
log.addHandler(handler)
|
||||
log.propagate = False
|
||||
return log
|
||||
|
||||
log = create_log('main')
|
||||
email_log = create_log('emails')
|
||||
web_log = create_log('waitress')
|
||||
auth_log = create_log('auth')
|
||||
|
||||
if args.host is not None:
|
||||
log.debug(f'Using specified host {args.host}')
|
||||
config['ui']['host'] = args.host
|
||||
if args.port is not None:
|
||||
log.debug(f'Using specified port {args.port}')
|
||||
config['ui']['port'] = args.port
|
||||
|
||||
for key in config['files']:
|
||||
if config['files'][key] == '':
|
||||
log.debug(f'Using default {key}')
|
||||
config['files'][key] = str(data_dir / (key + '.json'))
|
||||
|
||||
if config['email']['email_template'] == '':
|
||||
log.debug('Using default email HTML template')
|
||||
config['email']['email_template'] = str(local_dir / 'email.html')
|
||||
if config['email']['email_plaintext'] == '':
|
||||
log.debug('Using default email plaintext template')
|
||||
config['email']['email_plaintext'] = str(local_dir / 'email.txt')
|
||||
|
||||
if args.get_policy:
|
||||
import json
|
||||
from jellyfin_accounts.jf_api import Jellyfin
|
||||
@ -85,6 +119,12 @@ if args.get_policy:
|
||||
print(f'Policy written to "{config["files"]["user_template"]}".')
|
||||
print('In future, this policy will be copied to all new users.')
|
||||
else:
|
||||
def signal_handler(sig, frame):
|
||||
print('Quitting...')
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
app = Flask(__name__, root_path=str(local_dir))
|
||||
app.config['DEBUG'] = config.getboolean('ui', 'debug')
|
||||
app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
|
||||
@ -92,8 +132,18 @@ else:
|
||||
if __name__ == '__main__':
|
||||
import jellyfin_accounts.web_api
|
||||
import jellyfin_accounts.web
|
||||
print(jellyfin_accounts.web.__file__)
|
||||
from waitress import serve
|
||||
host = config['ui']['host']
|
||||
port = config['ui']['port']
|
||||
log.info(f'Starting web UI on {host}:{port}')
|
||||
if config.getboolean('email', 'enabled'):
|
||||
def start_pwr():
|
||||
import jellyfin_accounts.pw_reset
|
||||
jellyfin_accounts.pw_reset.start()
|
||||
pwr = threading.Thread(target=start_pwr, daemon=True)
|
||||
log.info('Starting email thread')
|
||||
pwr.start()
|
||||
|
||||
serve(app,
|
||||
host=config['ui']['host'],
|
||||
port=int(config['ui']['port']))
|
||||
host=host,
|
||||
port=int(port))
|
||||
|
5
setup.py
5
setup.py
@ -20,7 +20,10 @@ setup(
|
||||
],
|
||||
packages=find_packages(),
|
||||
# include_package_data=True,
|
||||
data_files=[('data', ['data/config-default.ini']),
|
||||
data_files=[('data', ['data/config-default.ini',
|
||||
'data/email.html',
|
||||
'data/email.mjml',
|
||||
'data/email.txt']),
|
||||
('data/static', ['data/static/admin.js']),
|
||||
('data/templates', [
|
||||
'data/templates/404.html',
|
||||
|
Loading…
Reference in New Issue
Block a user