mirror of
https://github.com/hrfee/jellyfin-accounts.git
synced 2024-12-22 00:50:12 +00:00
first commit
This commit is contained in:
commit
d321726d62
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
__pycache__
|
||||
notes.md
|
||||
MANIFEST.in
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
21
LICENSE
Normal file
21
LICENSE
Normal 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
87
README.md
Normal 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
39
data/config-default.ini
Normal 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
173
data/static/admin.js
Normal 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
26
data/templates/404.html
Normal 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
99
data/templates/admin.html
Normal 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
90
data/templates/form.html
Normal 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>
|
25
data/templates/invalidCode.html
Normal file
25
data/templates/invalidCode.html
Normal 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
BIN
images/admin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
BIN
images/create.png
Normal file
BIN
images/create.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
0
jellyfin_accounts/__init__.py
Normal file
0
jellyfin_accounts/__init__.py
Normal file
82
jellyfin_accounts/jf_api.py
Normal file
82
jellyfin_accounts/jf_api.py
Normal 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.
|
55
jellyfin_accounts/login.py
Normal file
55
jellyfin_accounts/login.py
Normal 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
41
jellyfin_accounts/web.py
Normal 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'])
|
150
jellyfin_accounts/web_api.py
Normal file
150
jellyfin_accounts/web_api.py
Normal 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
99
jf-accounts
Executable 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
42
setup.py
Normal 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',
|
||||
],
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user