first commit

This commit is contained in:
Harvey Tindall 2020-04-11 15:20:25 +01:00
commit d321726d62
18 changed files with 1035 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
__pycache__
notes.md
MANIFEST.in
dist/
build/
*.egg-info/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Harvey Tindall
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

87
README.md Normal file
View File

@ -0,0 +1,87 @@
# jellyfin-accounts
A simple, web-based invite system for [Jellyfin](https://github.com/jellyfin/jellyfin).
* 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)
## Screenshots
<p align="center">
<img src="images/admin.png" width="45%"></img> <img src="images/create.png" width="45%"></img>
</p>
## Install
```
git clone https://github.com/hrfee/jellyfin-accounts.git
cd jellyfin-accounts
python3 setup.py install
```
### Usage
* Passing no arguments will run the server
```
usage: jf-accounts [-h] [-c CONFIG] [-d DATA] [--host HOST] [-p PORT] [-g]
jellyfin-accounts
optional arguments:
-h, --help show this help message and exit
-c CONFIG, --config CONFIG
specifies path to configuration file.
-d DATA, --data DATA specifies directory to store data in. defaults to
~/.jf-accounts.
--host HOST address to host web ui on.
-p PORT, --port PORT port to host web ui on.
-g, --get_policy tool to grab a JF users policy (access, perms, etc.)
and output as json to be used as a user template.
```
### 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.
* 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.
### Configuration
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]
username = username
password = password
; Server will also be used in the invite form, so make sure it's publicly accessible.
server = https://jellyf.in:443
client = jf-accounts
version = 0.1
device = jf-accounts
device_id = jf-accounts-0.1
[ui]
host = 127.0.0.1
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.
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.
[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
emails =
; Path to the user policy template. Can be acquired with get-template.
user_template =
```
### Todo
* Fix pip install (possible related to using `data_files` over `package_data`?)
* Properly integrate with a janky password reset email system i've written.
* Improve `generateInvites` in admin.js to refresh each invite's data, instead of deleting and recreating them.
* Fix weird alignment of invite codes and the generate button (I know, i'm very new to web development)

39
data/config-default.ini Normal file
View File

@ -0,0 +1,39 @@
[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.
server = https://jellyf.in:443
client = jf-accounts
version = 0.1
device = jf-accounts
device_id = jf-accounts-0.1
[ui]
host = 127.0.0.1
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.
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.
[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
emails =
; Path to the user policy template. Can be acquired with get-template.
user_template =

173
data/static/admin.js Normal file
View File

@ -0,0 +1,173 @@
function addItem(invite) {
var links = document.getElementById('invites');
var listItem = document.createElement('li');
listItem.classList.add('list-group-item');
listItem.classList.add('align-middle');
var listCode = document.createElement('div');
listCode.classList.add('float-left');
var codeLink = document.createElement('a');
codeLink.appendChild(document.createTextNode(invite[0]));
listCode.appendChild(codeLink);
listItem.appendChild(listCode);
var listRight = document.createElement('div');
listRight.classList.add('float-right');
listRight.appendChild(document.createTextNode(invite[1]));
if (invite[2] == 0) {
var inviteCode = window.location.href + 'invite/' + invite[0];
codeLink.href = inviteCode;
listCode.appendChild(document.createTextNode(" "));
var codeCopy = document.createElement('i');
codeCopy.onclick = function(){toClipboard(inviteCode)};
codeCopy.classList.add('fa');
codeCopy.classList.add('fa-clipboard');
listCode.appendChild(codeCopy);
var listDelete = document.createElement('button');
listDelete.onclick = function(){deleteInvite(invite[0])};
listDelete.classList.add('btn');
listDelete.classList.add('btn-outline-danger');
listDelete.appendChild(document.createTextNode('Delete'));
listRight.appendChild(listDelete);
};
listItem.appendChild(listRight);
links.appendChild(listItem);
};
function generateInvites(empty = false) {
document.getElementById('invites').textContent = '';
if (empty === false) {
$.ajax('/getInvites', {
type : 'GET',
dataType : 'json',
contentType: 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
data: { get_param: 'value' },
complete: function(response) {
var data = JSON.parse(response['responseText']);
if (data['invites'].length == 0) {
addItem(["None", "", "1"]);
} else {
data['invites'].forEach(function(item) {
var i = ["", "", "0"];
i[0] = item['code'];
if (item['hours'] == 0) {
i[1] = item['minutes'] + 'm';
} else if (item['minutes'] == 0) {
i[1] = item['hours'] + 'h';
} else {
i[1] = item['hours'] + 'h ' + item['minutes'] + 'm';
}
i[1] = "Expires in " + i[1] + " ";
addItem(i)
});
}
}
});
} else if (empty === true) {
addItem(["None", "", "1"]);
};
};
function deleteInvite(code) {
var send = JSON.stringify({ "code": code });
$.ajax('/deleteInvite', {
data : send,
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() { generateInvites(); },
});
};
function addOptions(le, sel) {
for (v = 0; v <= le; v++) {
var opt = document.createElement('option');
opt.appendChild(document.createTextNode(v))
opt.value = v
sel.appendChild(opt)
}
};
function toClipboard(str) {
const el = document.createElement('textarea');
el.value = str;
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
const selected =
document.getSelection().rangeCount > 0
? document.getSelection().getRangeAt(0)
: false;
el.select();
document.execCommand('copy');
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
};
$("form#inviteForm").submit(function() {
var send = $("form#inviteForm").serializeJSON();
$.ajax('/generateInvite', {
data : send,
contentType : 'application/json',
type : 'POST',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
},
success: function() { generateInvites(); },
});
return false;
});
$("form#loginForm").submit(function() {
window.token = "";
var details = $("form#loginForm").serializeObject();
$.ajax('/getToken', {
type : 'GET',
dataType : 'json',
contentType: 'json',
xhrFields : {
withCredentials: true
},
beforeSend : function (xhr) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password']));
},
data: { get_param: 'value' },
complete: function(data) {
if (data['status'] == 401) {
var formBody = document.getElementById('formBody');
var wrongPassword = document.createElement('div');
wrongPassword.classList.add('alert');
wrongPassword.classList.add('alert-danger');
wrongPassword.setAttribute('role', 'alert');
wrongPassword.appendChild(document.createTextNode('Incorrect username or password.'));
formBody.appendChild(wrongPassword);
} else {
window.token = JSON.parse(data['responseText'])['token'];
generateInvites();
var interval = setInterval(function() { generateInvites(); }, 60 * 1000);
var hour = document.getElementById('hours');
addOptions(24, hour);
hour.selected = "0";
var minutes = document.getElementById('minutes');
addOptions(59, minutes);
minutes.selected = "30";
$('#login').modal('hide');
}
}
});
return false;
});
generateInvites(empty = true);
$("#login").modal('show');

26
data/templates/404.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Create Jellyfin Account</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Page not found.</h1>
<p>
{{ contactMessage }}
</p>
</div>
</body>
</html>

99
data/templates/admin.html Normal file
View File

@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
h1 {
margin: 20%;
margin-bottom: 5%;
}
.linkGroup {
margin: 20%;
margin-bottom: 5%;
margin-top: 5%;
}
.linkForm {
margin: 20%;
margin-top: 5%;
margin-bottom: 5%;
}
.contactBox {
margin: 20%;
margin-top: 5%;
color: grey;
}
.fa-clipboard {
color: grey;
}
.fa-clipboard:hover {
color: black;
}
</style>
<title>Admin</title>
</head>
<body>
<div class="modal fade" id="login" tabindex="-1" role="dialog" aria-labelledby="login" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginTitle">Login</h5>
</div>
<div class="modal-body" id="formBody">
<form action="#" method="POST" id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" class="form-control" id="username" name="username" placeholder="Username" required>
<label for="password">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
</div>
</form>
</div>
<div class="modal-footer">
<input type="submit" class="btn btn-primary" value="Login" form="loginForm">
</div>
</div>
</div>
</div>
<h1>
Accounts admin
</h1>
<div class="linkGroup">
Current Invites
<div class="list-group" id="invites">
</div>
</div>
<div class="linkForm">
Generate Invite
<div class="list-group">
<form action="#" method="POST" id="inviteForm">
<div class="form-group">
<div class="list-group-item">
<label for="hours">Hours</label>
<select class="form-control" id="hours" name="hours">
</select>
<label for="minutes">Minutes</label>
<select class="form-control" id="minutes" name="minutes">
</select>
<input type="submit" class="btn btn-primary" value="Generate">
</div>
</div>
</form>
</div>
</div>
<div class="contactBox">
<p>{{ contactMessage }}</p>
</div>
<script src="admin.js"></script>
</body>
</html>

90
data/templates/form.html Normal file
View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<style>
.pageTitle {
margin: 20%;
margin-bottom: 5%;
}
.accountForm {
margin: 20%;
margin-bottom: 5%;
margin-top: 5%;
}
.contactBox {
margin: 20%;
margin-top: 5%;
color: grey;
}
</style>
<title>Create Jellyfin Account</title>
</head>
<body>
<div class="modal fade" id="successBox" tabindex="-1" role="dialog" aria-labelledby="successBox" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="successTitle">Success!</h5>
</div>
<div class="modal-body" id="successBody">
<p>{{ successMessage }}</p>
</div>
<div class="modal-footer">
<a href="{{ jfLink }}" class="btn btn-primary">Continue</a>
</div>
</div>
</div>
</div>
<div class="pageTitle">
<h1>
Create Account
</h1>
<p>{{ helpMessage }}</p>
</div>
<div class="accountForm" id="accountForm">
<form action="#" method="POST">
<div class="form-group">
<label for="inputEmail">Email</label>
<input type="email" class="form-control" id="inputEmail" name="email" placeholder="Email" required>
</div>
<div class="form-group">
<label for="inputUsername">Username</label>
<input type="username" class="form-control" id="inputUsername" name="username" placeholder="Username" required>
</div>
<div class="form-group">
<label for="inputPassword">Password</label>
<input type="password" class="form-control" id="inputPassword" name="password" placeholder="Password" required>
</div>
<input type="submit" class="btn btn-primary" value="Create Account">
</form>
</div>
<div class="contactBox">
<p>{{ contactMessage }}</p>
</div>
<script>
$("form").submit(function() {
var send = $("form").serializeObject();
send['code'] = window.location.href.split('/').pop();
send = JSON.stringify(send);
$.ajax('/newUser', {
data : send,
contentType : 'application/json',
type : 'POST',
crossDomain: true,
success : function(){
$('#successBox').modal('show');
}
});
return false;
});
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Create Jellyfin Account</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<style>
.messageBox {
margin: 20%;
}
</style>
</head>
<body>
<div class="messageBox">
<h1>Invalid Code.</h1>
<p>The above code is either incorrect, or has expired.</p>
<p>{{ contactMessage }}</p>
</div>
</body>
</html>

BIN
images/admin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
images/create.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

View File

@ -0,0 +1,82 @@
#!/usr/bin/env python3
import requests
class Error(Exception):
pass
class Jellyfin:
class UserExistsError(Error):
pass
class UserNotFoundError(Error):
pass
class AuthenticationError(Error):
pass
class AuthenticationRequiredError(Error):
pass
def __init__(self, server, client, version, device, deviceId):
self.server = server
self.client = client
self.version = version
self.device = device
self.deviceId = deviceId
self.useragent = f"{self.client}/{self.version}"
self.auth = "MediaBrowser "
self.auth += f"Client={self.client}, "
self.auth += f"Device={self.device}, "
self.auth += f"DeviceId={self.deviceId}, "
self.auth += f"Version={self.version}"
self.header = {
"Accept": "application/json",
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Application": f"{self.client}/{self.version}",
"Accept-Charset": "UTF-8,*",
"Accept-encoding": "gzip",
"User-Agent": self.useragent,
"X-Emby-Authorization": self.auth
}
def getUsers(self, username="all"):
response = requests.get(self.server+"/emby/Users/Public").json()
if username == "all":
return response
else:
match = False
for user in response:
if user['Name'] == username:
match = True
return user
if not match:
raise self.UserNotFoundError
def authenticate(self, username, password):
self.username = username
self.password = password
response = requests.post(self.server+"/emby/Users/AuthenticateByName",
headers=self.header,
params={'Username': self.username,
'Pw': self.password})
if response.status_code == 200:
json = response.json()
self.userId = json['User']['Id']
self.accessToken = json['AccessToken']
self.auth += f", Token={self.accessToken}"
self.header['X-Emby-Authorization'] = self.auth
return True
else:
raise self.AuthenticationError
def setPolicy(self, userId, policy):
return requests.post(self.server+"/Users/"+userId+"/Policy",
headers=self.header,
params=policy)
def newUser(self, username, password):
for user in self.getUsers():
if user['Name'] == username:
raise self.UserExistsError
response = requests.post(self.server+"/emby/Users/New",
headers=self.header,
params={'Name': username,
'Password': password})
if response.status_code == 401:
raise self.AuthenticationRequiredError
return response
# template user's policies should be copied to each new account.

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
# from flask import g
from flask_httpauth import HTTPBasicAuth
from itsdangerous import (TimedJSONWebSignatureSerializer
as Serializer, BadSignature, SignatureExpired)
from passlib.apps import custom_app_context as pwd_context
import uuid
from __main__ import config, app, g
class Account():
def __init__(self, username, password):
self.username = username
self.password_hash = pwd_context.hash(password)
self.id = str(uuid.uuid4())
def verify_password(self, password):
return pwd_context.verify(password, self.password_hash)
def generate_token(self, expiration=1200):
s = Serializer(app.config['SECRET_KEY'], expires_in=expiration)
return s.dumps({ 'id': self.id })
@staticmethod
def verify_token(token, account):
s = Serializer(app.config['SECRET_KEY'])
try:
data = s.loads(token)
except SignatureExpired:
return None
except BadSignature:
return None
if data['id'] == account.id:
return account
auth = HTTPBasicAuth()
adminAccount = Account(config['ui']['username'], config['ui']['password'])
@auth.verify_password
def verify_password(username, password):
user = adminAccount.verify_token(username, adminAccount)
if not user:
if username == adminAccount.username and adminAccount.verify_password(password):
g.user = adminAccount
print(g)
return True
else:
return False
g.user = adminAccount
return True

41
jellyfin_accounts/web.py Normal file
View File

@ -0,0 +1,41 @@
from pathlib import Path
from flask import Flask, send_from_directory, render_template
from __main__ import config, app, g
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html',
contactMessage=config['ui']['contact_message']), 404
@app.route('/', methods=['GET', 'POST'])
def admin():
# return app.send_static_file('admin.html')
return render_template('admin.html',
contactMessage='')
@app.route('/<path:path>')
def static_proxy(path):
if 'form.html' not in path and 'admin.html' not in path:
return app.send_static_file(path)
return render_template('404.html',
contactMessage=config['ui']['contact_message']), 404
from jellyfin_accounts.web_api import checkInvite
@app.route('/invite/<path:path>')
def inviteProxy(path):
if checkInvite(path):
return render_template('form.html',
contactMessage=config['ui']['contact_message'],
helpMessage=config['ui']['help_message'],
successMessage=config['ui']['success_message'],
jfLink=config['jellyfin']['server'])
elif 'admin.html' not in path and 'admin.html' not in path:
return app.send_static_file(path)
else:
return render_template('invalidCode.html',
contactMessage=config['ui']['contact_message'])

View File

@ -0,0 +1,150 @@
from flask import request, jsonify
from jellyfin_accounts.jf_api import Jellyfin
import json
import datetime
import secrets
from __main__ import config, app, g
from jellyfin_accounts.login import auth
def resp(success=True, code=500):
if success:
r = jsonify({'success': True})
r.status_code = 200
else:
r = jsonify({'success': False})
r.status_code = code
return r
def checkInvite(code, delete=False):
current_time = datetime.datetime.now()
try:
with open(config['files']['invites'], 'r') as f:
invites = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError):
invites = {'invites': []}
valid = False
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:
del invites['invites'][index]
else:
if i['code'] == code:
valid = True
if delete:
del invites['invites'][index]
with open(config['files']['invites'], 'w') as f:
f.write(json.dumps(invites, indent=4, default=str))
return valid
jf = Jellyfin(config['jellyfin']['server'],
config['jellyfin']['client'],
config['jellyfin']['version'],
config['jellyfin']['device'],
config['jellyfin']['device_id'])
jf.authenticate(config['jellyfin']['username'],
config['jellyfin']['password'])
@app.route('/newUser', methods=['GET', 'POST'])
def newUser():
data = request.get_json()
if checkInvite(data['code'], delete=True):
user = jf.newUser(data['username'], data['password'])
if user.status_code == 200:
try:
with open(config['files']['user_template'], 'r') as f:
default_policy = json.load(f)
jf.setPolicy(user.json()['Id'], default_policy)
except:
pass
if config['ui']['emails_enabled'] == 'true':
try:
with open(config['files']['emails'], 'r') as f:
emails = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError):
emails = {}
emails[data['username']] = data['email']
with open(config['files']['emails'], 'w') as f:
f.write(json.dumps(emails, indent=4))
return resp()
else:
return resp(False)
else:
return resp(False, code=401)
@app.route('/generateInvite', methods=['GET', 'POST'])
@auth.login_required
def generateInvite():
current_time = datetime.datetime.now()
data = request.get_json()
delta = datetime.timedelta(hours=int(data['hours']),
minutes=int(data['minutes']))
invite = {'code': secrets.token_urlsafe(16)}
invite['valid_till'] = (current_time +
delta).strftime('%Y-%m-%dT%H:%M:%S.%f')
try:
with open(config['files']['invites'], 'r') as f:
invites = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError):
invites = {'invites': []}
invites['invites'].append(invite)
with open(config['files']['invites'], 'w') as f:
f.write(json.dumps(invites, indent=4, default=str))
return resp()
@app.route('/getInvites', methods=['GET'])
@auth.login_required
def getInvites():
current_time = datetime.datetime.now()
try:
with open(config['files']['invites'], 'r') as f:
invites = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError):
invites = {'invites': []}
response = {'invites': []}
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:
del invites['invites'][index]
else:
valid_for = expiry - current_time
response['invites'].append({
'code': i['code'],
'hours': valid_for.seconds//3600,
'minutes': (valid_for.seconds//60) % 60})
with open(config['files']['invites'], 'w') as f:
f.write(json.dumps(invites, indent=4, default=str))
return jsonify(response)
@app.route('/deleteInvite', methods=['POST'])
@auth.login_required
def deleteInvite():
code = request.get_json()['code']
try:
with open(config['files']['invites'], 'r') as f:
invites = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError):
invites = {'invites': []}
for index, i in enumerate(invites['invites']):
if i['code'] == code:
del invites['invites'][index]
with open(config['files']['invites'], 'w') as f:
f.write(json.dumps(invites, indent=4, default=str))
return resp()
@app.route('/getToken')
@auth.login_required
def get_token():
token = g.user.generate_token()
return jsonify({'token': token.decode('ascii')})

99
jf-accounts Executable file
View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
import secrets
import configparser
import shutil
import argparse
from pathlib import Path
from flask import Flask, g
parser = argparse.ArgumentParser(description="jellyfin-accounts")
parser.add_argument("-c", "--config",
help="specifies path to configuration file.")
parser.add_argument("-d", "--data",
help=("specifies directory to store data in. " +
"defaults to ~/.jf-accounts."))
parser.add_argument("--host",
help="address to host web ui on.")
parser.add_argument("-p", "--port",
help="port to host web ui on.")
parser.add_argument("-g", "--get_policy",
help=("tool to grab a JF users " +
"policy (access, perms, etc.) and " +
"output as json to be used as a user template."),
action='store_true')
args, leftovers = parser.parse_known_args()
if args.data is not None:
data_dir = Path(args.data)
else:
data_dir = Path.home() / '.jf-accounts'
local_dir = (Path(__file__).parents[2] / 'data').resolve()
if not data_dir.exists():
Path.mkdir(data_dir)
print(f'Config dir not found, so created at {str(data_dir)}')
if args.config is None:
config_path = data_dir / 'config.ini'
shutil.copy(str(local_dir / 'config-default.ini'),
str(config_path))
print("Edit the configuration and restart.")
raise SystemExit
else:
config_path = Path(args.config)
print(f'config.ini can be found at {str(config_path)}')
else:
config_path = data_dir / 'config.ini'
config = configparser.ConfigParser()
config.read(config_path)
if args.host is not None:
config['ui']['host'] = args.host
if args.port is not None:
config['ui']['port'] = args.port
for key in config['files']:
if config['files'][key] == '':
config['files'][key] = str(data_dir / (key + '.json'))
if args.get_policy:
import json
from jellyfin_accounts.jf_api import Jellyfin
jf = Jellyfin(config['jellyfin']['server'],
config['jellyfin']['client'],
config['jellyfin']['version'],
config['jellyfin']['device'],
config['jellyfin']['device_id'])
# No auth needed.
print("Make sure the user is publicly visible!")
users = jf.getUsers()
for index, user in enumerate(users):
print(f'{index+1}) {user["Name"]}')
success = False
while not success:
try:
policy = users[int(input(">: "))-1]['Policy']
success = True
except (ValueError, IndexError):
pass
with open(config['files']['user_template'], 'w') as f:
f.write(json.dumps(policy, indent=4))
print(f'Policy written to "{config["files"]["user_template"]}".')
print('In future, this policy will be copied to all new users.')
else:
app = Flask(__name__, root_path=str(local_dir))
app.config['DEBUG'] = config.getboolean('ui', 'debug')
app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
if __name__ == '__main__':
import jellyfin_accounts.web_api
import jellyfin_accounts.web
print(jellyfin_accounts.web.__file__)
from waitress import serve
serve(app,
host=config['ui']['host'],
port=int(config['ui']['port']))

42
setup.py Normal file
View File

@ -0,0 +1,42 @@
from setuptools import find_packages, setup
with open('README.md', 'r') as f:
long_description = f.read()
setup(
name='jellyfin-accounts',
version='0.1',
scripts=['jf-accounts'],
author="Harvey Tindall",
author_email="hrfee@protonmail.ch",
description="A simple invite system for Jellyfin",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/hrfee/jellyfin-accounts",
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
packages=find_packages(),
# include_package_data=True,
data_files=[('data', ['data/config-default.ini']),
('data/static', ['data/static/admin.js']),
('data/templates', [
'data/templates/404.html',
'data/templates/invalidCode.html',
'data/templates/admin.html',
'data/templates/form.html'])],
zip_safe=False,
install_requires=[
'flask',
'flask_httpauth',
'requests',
'itsdangerous',
'passlib',
'secrets',
'configparser',
'waitress',
],
)