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