Make Kobo optional
move jsonschema dependency to optional-requirements.txt Added version of jsonschema to about section Added additional column to RemoteAuthToken table Update configuration of Kobo sync protocol
This commit is contained in:
parent
2798dd5916
commit
79a9ef4859
6
cps.py
6
cps.py
|
@ -41,8 +41,13 @@ from cps.shelf import shelf
|
||||||
from cps.admin import admi
|
from cps.admin import admi
|
||||||
from cps.gdrive import gdrive
|
from cps.gdrive import gdrive
|
||||||
from cps.editbooks import editbook
|
from cps.editbooks import editbook
|
||||||
|
|
||||||
|
try:
|
||||||
from cps.kobo import kobo
|
from cps.kobo import kobo
|
||||||
from cps.kobo_auth import kobo_auth
|
from cps.kobo_auth import kobo_auth
|
||||||
|
kobo_available = True
|
||||||
|
except ImportError:
|
||||||
|
kobo_available = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cps.oauth_bb import oauth
|
from cps.oauth_bb import oauth
|
||||||
|
@ -61,6 +66,7 @@ def main():
|
||||||
app.register_blueprint(admi)
|
app.register_blueprint(admi)
|
||||||
app.register_blueprint(gdrive)
|
app.register_blueprint(gdrive)
|
||||||
app.register_blueprint(editbook)
|
app.register_blueprint(editbook)
|
||||||
|
if kobo_available:
|
||||||
app.register_blueprint(kobo)
|
app.register_blueprint(kobo)
|
||||||
app.register_blueprint(kobo_auth)
|
app.register_blueprint(kobo_auth)
|
||||||
if oauth_available:
|
if oauth_available:
|
||||||
|
|
|
@ -67,6 +67,7 @@ _VERSIONS = OrderedDict(
|
||||||
Unidecode = unidecode_version,
|
Unidecode = unidecode_version,
|
||||||
Flask_SimpleLDAP = u'installed' if bool(services.ldap) else u'not installed',
|
Flask_SimpleLDAP = u'installed' if bool(services.ldap) else u'not installed',
|
||||||
Goodreads = u'installed' if bool(services.goodreads_support) else u'not installed',
|
Goodreads = u'installed' if bool(services.goodreads_support) else u'not installed',
|
||||||
|
jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else u'not installed',
|
||||||
|
|
||||||
)
|
)
|
||||||
_VERSIONS.update(uploader.get_versions())
|
_VERSIONS.update(uploader.get_versions())
|
||||||
|
|
47
cps/admin.py
47
cps/admin.py
|
@ -45,7 +45,8 @@ from .web import admin_required, render_title_template, before_request, unconfig
|
||||||
|
|
||||||
feature_support = {
|
feature_support = {
|
||||||
'ldap': False, # bool(services.ldap),
|
'ldap': False, # bool(services.ldap),
|
||||||
'goodreads': bool(services.goodreads_support)
|
'goodreads': bool(services.goodreads_support),
|
||||||
|
'kobo': bool(services.kobo)
|
||||||
}
|
}
|
||||||
|
|
||||||
# try:
|
# try:
|
||||||
|
@ -63,6 +64,7 @@ except ImportError:
|
||||||
oauth_check = {}
|
oauth_check = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
feature_support['gdrive'] = gdrive_support
|
feature_support['gdrive'] = gdrive_support
|
||||||
admi = Blueprint('admin', __name__)
|
admi = Blueprint('admin', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
@ -568,7 +570,7 @@ def _configuration_update_helper():
|
||||||
# Remote login configuration
|
# Remote login configuration
|
||||||
_config_checkbox("config_remote_login")
|
_config_checkbox("config_remote_login")
|
||||||
if not config.config_remote_login:
|
if not config.config_remote_login:
|
||||||
ub.session.query(ub.RemoteAuthToken).delete()
|
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.token_type==0).delete()
|
||||||
|
|
||||||
# Goodreads configuration
|
# Goodreads configuration
|
||||||
_config_checkbox("config_use_goodreads")
|
_config_checkbox("config_use_goodreads")
|
||||||
|
@ -693,7 +695,8 @@ def new_user():
|
||||||
if not to_save["nickname"] or not to_save["email"] or not to_save["password"]:
|
if not to_save["nickname"] or not to_save["email"] or not to_save["password"]:
|
||||||
flash(_(u"Please fill out all fields!"), category="error")
|
flash(_(u"Please fill out all fields!"), category="error")
|
||||||
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
|
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
|
||||||
registered_oauth=oauth_check, title=_(u"Add new user"))
|
registered_oauth=oauth_check, feature_support=feature_support,
|
||||||
|
title=_(u"Add new user"))
|
||||||
content.password = generate_password_hash(to_save["password"])
|
content.password = generate_password_hash(to_save["password"])
|
||||||
existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"].lower())\
|
existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"].lower())\
|
||||||
.first()
|
.first()
|
||||||
|
@ -704,14 +707,15 @@ def new_user():
|
||||||
if config.config_public_reg and not check_valid_domain(to_save["email"]):
|
if config.config_public_reg and not check_valid_domain(to_save["email"]):
|
||||||
flash(_(u"E-mail is not from valid domain"), category="error")
|
flash(_(u"E-mail is not from valid domain"), category="error")
|
||||||
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
|
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
|
||||||
registered_oauth=oauth_check, title=_(u"Add new user"))
|
registered_oauth=oauth_check, feature_support=feature_support,
|
||||||
|
title=_(u"Add new user"))
|
||||||
else:
|
else:
|
||||||
content.email = to_save["email"]
|
content.email = to_save["email"]
|
||||||
else:
|
else:
|
||||||
flash(_(u"Found an existing account for this e-mail address or nickname."), category="error")
|
flash(_(u"Found an existing account for this e-mail address or nickname."), category="error")
|
||||||
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
|
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
|
||||||
languages=languages, title=_(u"Add new user"), page="newuser",
|
languages=languages, title=_(u"Add new user"), page="newuser",
|
||||||
registered_oauth=oauth_check)
|
feature_support=feature_support, registered_oauth=oauth_check)
|
||||||
try:
|
try:
|
||||||
ub.session.add(content)
|
ub.session.add(content)
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
|
@ -729,7 +733,7 @@ def new_user():
|
||||||
# content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT)
|
# content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT)
|
||||||
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
|
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
|
||||||
languages=languages, title=_(u"Add new user"), page="newuser",
|
languages=languages, title=_(u"Add new user"), page="newuser",
|
||||||
registered_oauth=oauth_check)
|
feature_support=feature_support, registered_oauth=oauth_check)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/mailsettings")
|
@admi.route("/admin/mailsettings")
|
||||||
|
@ -850,8 +854,14 @@ def edit_user(user_id):
|
||||||
content.kobo_user_key_hash = kobo_user_key_hash
|
content.kobo_user_key_hash = kobo_user_key_hash
|
||||||
else:
|
else:
|
||||||
flash(_(u"Found an existing account for this Kobo UserKey."), category="error")
|
flash(_(u"Found an existing account for this Kobo UserKey."), category="error")
|
||||||
return render_title_template("user_edit.html", translations=translations, languages=languages,
|
return render_title_template("user_edit.html",
|
||||||
new_user=0, content=content, downloads=downloads, registered_oauth=oauth_check,
|
translations=translations,
|
||||||
|
languages=languages,
|
||||||
|
new_user=0,
|
||||||
|
content=content,
|
||||||
|
downloads=downloads,
|
||||||
|
registered_oauth=oauth_check,
|
||||||
|
feature_support=feature_support,
|
||||||
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
|
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
|
||||||
if to_save["email"] and to_save["email"] != content.email:
|
if to_save["email"] and to_save["email"] != content.email:
|
||||||
existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \
|
existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \
|
||||||
|
@ -860,9 +870,15 @@ def edit_user(user_id):
|
||||||
content.email = to_save["email"]
|
content.email = to_save["email"]
|
||||||
else:
|
else:
|
||||||
flash(_(u"Found an existing account for this e-mail address."), category="error")
|
flash(_(u"Found an existing account for this e-mail address."), category="error")
|
||||||
return render_title_template("user_edit.html", translations=translations, languages=languages,
|
return render_title_template("user_edit.html",
|
||||||
|
translations=translations,
|
||||||
|
languages=languages,
|
||||||
mail_configured = config.get_mail_server_configured(),
|
mail_configured = config.get_mail_server_configured(),
|
||||||
new_user=0, content=content, downloads=downloads, registered_oauth=oauth_check,
|
feature_support=feature_support,
|
||||||
|
new_user=0,
|
||||||
|
content=content,
|
||||||
|
downloads=downloads,
|
||||||
|
registered_oauth=oauth_check,
|
||||||
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
|
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
|
||||||
if "nickname" in to_save and to_save["nickname"] != content.nickname:
|
if "nickname" in to_save and to_save["nickname"] != content.nickname:
|
||||||
# Query User nickname, if not existing, change
|
# Query User nickname, if not existing, change
|
||||||
|
@ -877,6 +893,7 @@ def edit_user(user_id):
|
||||||
new_user=0, content=content,
|
new_user=0, content=content,
|
||||||
downloads=downloads,
|
downloads=downloads,
|
||||||
registered_oauth=oauth_check,
|
registered_oauth=oauth_check,
|
||||||
|
feature_support=feature_support,
|
||||||
title=_(u"Edit User %(nick)s", nick=content.nickname),
|
title=_(u"Edit User %(nick)s", nick=content.nickname),
|
||||||
page="edituser")
|
page="edituser")
|
||||||
|
|
||||||
|
@ -888,9 +905,15 @@ def edit_user(user_id):
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
flash(_(u"An unknown error occured."), category="error")
|
flash(_(u"An unknown error occured."), category="error")
|
||||||
return render_title_template("user_edit.html", translations=translations, languages=languages, new_user=0,
|
return render_title_template("user_edit.html",
|
||||||
content=content, downloads=downloads, registered_oauth=oauth_check,
|
translations=translations,
|
||||||
|
languages=languages,
|
||||||
|
new_user=0,
|
||||||
|
content=content,
|
||||||
|
downloads=downloads,
|
||||||
|
registered_oauth=oauth_check,
|
||||||
mail_configured=config.get_mail_server_configured(),
|
mail_configured=config.get_mail_server_configured(),
|
||||||
|
feature_support=feature_support,
|
||||||
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
|
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
|
||||||
|
|
||||||
|
|
||||||
|
|
127
cps/kobo.py
127
cps/kobo.py
|
@ -17,10 +17,8 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from base64 import b64decode, b64encode
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from time import gmtime, strftime
|
from time import gmtime, strftime
|
||||||
try:
|
try:
|
||||||
|
@ -28,14 +26,12 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from jsonschema import validate, exceptions
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
request,
|
request,
|
||||||
make_response,
|
make_response,
|
||||||
jsonify,
|
jsonify,
|
||||||
json,
|
json,
|
||||||
current_app,
|
|
||||||
url_for,
|
url_for,
|
||||||
redirect,
|
redirect,
|
||||||
)
|
)
|
||||||
|
@ -44,7 +40,7 @@ from werkzeug.datastructures import Headers
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from . import config, logger, kobo_auth, db, helper
|
from . import config, logger, kobo_auth, db, helper, services
|
||||||
from .web import download_required
|
from .web import download_required
|
||||||
|
|
||||||
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB", "EPUB3"]}
|
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB", "EPUB3"]}
|
||||||
|
@ -56,19 +52,6 @@ kobo_auth.register_url_value_preprocessor(kobo)
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
def b64encode_json(json_data):
|
|
||||||
if sys.version_info < (3, 0):
|
|
||||||
return b64encode(json.dumps(json_data))
|
|
||||||
else:
|
|
||||||
return b64encode(json.dumps(json_data).encode())
|
|
||||||
|
|
||||||
|
|
||||||
# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2.
|
|
||||||
def to_epoch_timestamp(datetime_object):
|
|
||||||
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
|
|
||||||
|
|
||||||
|
|
||||||
def get_store_url_for_current_request():
|
def get_store_url_for_current_request():
|
||||||
# Programmatically modify the current url to point to the official Kobo store
|
# Programmatically modify the current url to point to the official Kobo store
|
||||||
base, sep, request_path_with_auth_token = request.full_path.rpartition("/kobo/")
|
base, sep, request_path_with_auth_token = request.full_path.rpartition("/kobo/")
|
||||||
|
@ -110,117 +93,11 @@ def redirect_or_proxy_request():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SyncToken:
|
|
||||||
""" The SyncToken is used to persist state accross requests.
|
|
||||||
When serialized over the response headers, the Kobo device will propagate the token onto following requests to the service.
|
|
||||||
As an example use-case, the SyncToken is used to detect books that have been added to the library since the last time the device synced to the server.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
books_last_created: Datetime representing the newest book that the device knows about.
|
|
||||||
books_last_modified: Datetime representing the last modified book that the device knows about.
|
|
||||||
"""
|
|
||||||
|
|
||||||
SYNC_TOKEN_HEADER = "x-kobo-synctoken"
|
|
||||||
VERSION = "1-0-0"
|
|
||||||
MIN_VERSION = "1-0-0"
|
|
||||||
|
|
||||||
token_schema = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"version": {"type": "string"}, "data": {"type": "object"},},
|
|
||||||
}
|
|
||||||
# This Schema doesn't contain enough information to detect and propagate book deletions from Calibre to the device.
|
|
||||||
# A potential solution might be to keep a list of all known book uuids in the token, and look for any missing from the db.
|
|
||||||
data_schema_v1 = {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"raw_kobo_store_token": {"type": "string"},
|
|
||||||
"books_last_modified": {"type": "string"},
|
|
||||||
"books_last_created": {"type": "string"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
raw_kobo_store_token="",
|
|
||||||
books_last_created=datetime.min,
|
|
||||||
books_last_modified=datetime.min,
|
|
||||||
):
|
|
||||||
self.raw_kobo_store_token = raw_kobo_store_token
|
|
||||||
self.books_last_created = books_last_created
|
|
||||||
self.books_last_modified = books_last_modified
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_headers(headers):
|
|
||||||
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
|
|
||||||
if sync_token_header == "":
|
|
||||||
return SyncToken()
|
|
||||||
|
|
||||||
# On the first sync from a Kobo device, we may receive the SyncToken
|
|
||||||
# from the official Kobo store. Without digging too deep into it, that
|
|
||||||
# token is of the form [b64encoded blob].[b64encoded blob 2]
|
|
||||||
if "." in sync_token_header:
|
|
||||||
return SyncToken(raw_kobo_store_token=sync_token_header)
|
|
||||||
|
|
||||||
try:
|
|
||||||
sync_token_json = json.loads(
|
|
||||||
b64decode(sync_token_header + "=" * (-len(sync_token_header) % 4))
|
|
||||||
)
|
|
||||||
validate(sync_token_json, SyncToken.token_schema)
|
|
||||||
if sync_token_json["version"] < SyncToken.MIN_VERSION:
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
data_json = sync_token_json["data"]
|
|
||||||
validate(sync_token_json, SyncToken.data_schema_v1)
|
|
||||||
except (exceptions.ValidationError, ValueError) as e:
|
|
||||||
log.error("Sync token contents do not follow the expected json schema.")
|
|
||||||
return SyncToken()
|
|
||||||
|
|
||||||
raw_kobo_store_token = data_json["raw_kobo_store_token"]
|
|
||||||
try:
|
|
||||||
books_last_modified = datetime.utcfromtimestamp(
|
|
||||||
data_json["books_last_modified"]
|
|
||||||
)
|
|
||||||
books_last_created = datetime.utcfromtimestamp(
|
|
||||||
data_json["books_last_created"]
|
|
||||||
)
|
|
||||||
except TypeError:
|
|
||||||
log.error("SyncToken timestamps don't parse to a datetime.")
|
|
||||||
return SyncToken(raw_kobo_store_token=raw_kobo_store_token)
|
|
||||||
|
|
||||||
return SyncToken(
|
|
||||||
raw_kobo_store_token=raw_kobo_store_token,
|
|
||||||
books_last_created=books_last_created,
|
|
||||||
books_last_modified=books_last_modified,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_kobo_store_header(self, store_headers):
|
|
||||||
store_headers.set(SyncToken.SYNC_TOKEN_HEADER, self.raw_kobo_store_token)
|
|
||||||
|
|
||||||
def merge_from_store_response(self, store_response):
|
|
||||||
self.raw_kobo_store_token = store_response.headers.get(
|
|
||||||
SyncToken.SYNC_TOKEN_HEADER, ""
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_headers(self, headers):
|
|
||||||
headers[SyncToken.SYNC_TOKEN_HEADER] = self.build_sync_token()
|
|
||||||
|
|
||||||
def build_sync_token(self):
|
|
||||||
token = {
|
|
||||||
"version": SyncToken.VERSION,
|
|
||||||
"data": {
|
|
||||||
"raw_kobo_store_token": self.raw_kobo_store_token,
|
|
||||||
"books_last_modified": to_epoch_timestamp(self.books_last_modified),
|
|
||||||
"books_last_created": to_epoch_timestamp(self.books_last_created),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return b64encode_json(token)
|
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/sync")
|
@kobo.route("/v1/library/sync")
|
||||||
@login_required
|
@login_required
|
||||||
@download_required
|
@download_required
|
||||||
def HandleSyncRequest():
|
def HandleSyncRequest():
|
||||||
sync_token = SyncToken.from_headers(request.headers)
|
sync_token = services.SyncToken.from_headers(request.headers)
|
||||||
log.info("Kobo library sync request received.")
|
log.info("Kobo library sync request received.")
|
||||||
|
|
||||||
# TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header
|
# TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header
|
||||||
|
|
|
@ -95,7 +95,7 @@ def load_user_from_kobo_request(request):
|
||||||
user = (
|
user = (
|
||||||
ub.session.query(ub.User)
|
ub.session.query(ub.User)
|
||||||
.join(ub.RemoteAuthToken)
|
.join(ub.RemoteAuthToken)
|
||||||
.filter(ub.RemoteAuthToken.auth_token == auth_token)
|
.filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
|
@ -108,18 +108,20 @@ def load_user_from_kobo_request(request):
|
||||||
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
||||||
|
|
||||||
|
|
||||||
@kobo_auth.route("/generate_auth_token")
|
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def generate_auth_token():
|
def generate_auth_token(user_id):
|
||||||
# Invalidate any prevously generated Kobo Auth token for this user.
|
# Invalidate any prevously generated Kobo Auth token for this user.
|
||||||
ub.session.query(ub.RemoteAuthToken).filter(
|
auth_token = ub.session.query(ub.RemoteAuthToken).filter(
|
||||||
ub.RemoteAuthToken.user_id == current_user.id
|
ub.RemoteAuthToken.user_id == user_id
|
||||||
).delete()
|
).filter(ub.RemoteAuthToken.token_type==1).first()
|
||||||
|
|
||||||
|
if not auth_token:
|
||||||
auth_token = ub.RemoteAuthToken()
|
auth_token = ub.RemoteAuthToken()
|
||||||
auth_token.user_id = current_user.id
|
auth_token.user_id = user_id
|
||||||
auth_token.expiration = datetime.max
|
auth_token.expiration = datetime.max
|
||||||
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
|
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
|
||||||
|
auth_token.token_type = 1
|
||||||
|
|
||||||
ub.session.add(auth_token)
|
ub.session.add(auth_token)
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
|
@ -131,3 +133,13 @@ def generate_auth_token():
|
||||||
"kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True
|
"kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@kobo_auth.route("/deleteauthtoken/<int:user_id>")
|
||||||
|
@login_required
|
||||||
|
def delete_auth_token(user_id):
|
||||||
|
# Invalidate any prevously generated Kobo Auth token for this user.
|
||||||
|
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||||
|
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
||||||
|
ub.session.commit()
|
||||||
|
return ""
|
||||||
|
|
148
cps/services/SyncToken.py
Normal file
148
cps/services/SyncToken.py
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from base64 import b64decode, b64encode
|
||||||
|
from jsonschema import validate, exceptions, __version__
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
from urllib import unquote
|
||||||
|
except ImportError:
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from flask import json
|
||||||
|
from .. import logger as log
|
||||||
|
|
||||||
|
|
||||||
|
def b64encode_json(json_data):
|
||||||
|
if sys.version_info < (3, 0):
|
||||||
|
return b64encode(json.dumps(json_data))
|
||||||
|
else:
|
||||||
|
return b64encode(json.dumps(json_data).encode())
|
||||||
|
|
||||||
|
|
||||||
|
# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2.
|
||||||
|
def to_epoch_timestamp(datetime_object):
|
||||||
|
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
|
class SyncToken:
|
||||||
|
""" The SyncToken is used to persist state accross requests.
|
||||||
|
When serialized over the response headers, the Kobo device will propagate the token onto following requests to the service.
|
||||||
|
As an example use-case, the SyncToken is used to detect books that have been added to the library since the last time the device synced to the server.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
books_last_created: Datetime representing the newest book that the device knows about.
|
||||||
|
books_last_modified: Datetime representing the last modified book that the device knows about.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SYNC_TOKEN_HEADER = "x-kobo-synctoken"
|
||||||
|
VERSION = "1-0-0"
|
||||||
|
MIN_VERSION = "1-0-0"
|
||||||
|
|
||||||
|
token_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"version": {"type": "string"}, "data": {"type": "object"},},
|
||||||
|
}
|
||||||
|
# This Schema doesn't contain enough information to detect and propagate book deletions from Calibre to the device.
|
||||||
|
# A potential solution might be to keep a list of all known book uuids in the token, and look for any missing from the db.
|
||||||
|
data_schema_v1 = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"raw_kobo_store_token": {"type": "string"},
|
||||||
|
"books_last_modified": {"type": "string"},
|
||||||
|
"books_last_created": {"type": "string"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
raw_kobo_store_token="",
|
||||||
|
books_last_created=datetime.min,
|
||||||
|
books_last_modified=datetime.min,
|
||||||
|
):
|
||||||
|
self.raw_kobo_store_token = raw_kobo_store_token
|
||||||
|
self.books_last_created = books_last_created
|
||||||
|
self.books_last_modified = books_last_modified
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_headers(headers):
|
||||||
|
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
|
||||||
|
if sync_token_header == "":
|
||||||
|
return SyncToken()
|
||||||
|
|
||||||
|
# On the first sync from a Kobo device, we may receive the SyncToken
|
||||||
|
# from the official Kobo store. Without digging too deep into it, that
|
||||||
|
# token is of the form [b64encoded blob].[b64encoded blob 2]
|
||||||
|
if "." in sync_token_header:
|
||||||
|
return SyncToken(raw_kobo_store_token=sync_token_header)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sync_token_json = json.loads(
|
||||||
|
b64decode(sync_token_header + "=" * (-len(sync_token_header) % 4))
|
||||||
|
)
|
||||||
|
validate(sync_token_json, SyncToken.token_schema)
|
||||||
|
if sync_token_json["version"] < SyncToken.MIN_VERSION:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
data_json = sync_token_json["data"]
|
||||||
|
validate(sync_token_json, SyncToken.data_schema_v1)
|
||||||
|
except (exceptions.ValidationError, ValueError) as e:
|
||||||
|
log.error("Sync token contents do not follow the expected json schema.")
|
||||||
|
return SyncToken()
|
||||||
|
|
||||||
|
raw_kobo_store_token = data_json["raw_kobo_store_token"]
|
||||||
|
try:
|
||||||
|
books_last_modified = datetime.utcfromtimestamp(
|
||||||
|
data_json["books_last_modified"]
|
||||||
|
)
|
||||||
|
books_last_created = datetime.utcfromtimestamp(
|
||||||
|
data_json["books_last_created"]
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
log.error("SyncToken timestamps don't parse to a datetime.")
|
||||||
|
return SyncToken(raw_kobo_store_token=raw_kobo_store_token)
|
||||||
|
|
||||||
|
return SyncToken(
|
||||||
|
raw_kobo_store_token=raw_kobo_store_token,
|
||||||
|
books_last_created=books_last_created,
|
||||||
|
books_last_modified=books_last_modified,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_kobo_store_header(self, store_headers):
|
||||||
|
store_headers.set(SyncToken.SYNC_TOKEN_HEADER, self.raw_kobo_store_token)
|
||||||
|
|
||||||
|
def merge_from_store_response(self, store_response):
|
||||||
|
self.raw_kobo_store_token = store_response.headers.get(
|
||||||
|
SyncToken.SYNC_TOKEN_HEADER, ""
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_headers(self, headers):
|
||||||
|
headers[SyncToken.SYNC_TOKEN_HEADER] = self.build_sync_token()
|
||||||
|
|
||||||
|
def build_sync_token(self):
|
||||||
|
token = {
|
||||||
|
"version": SyncToken.VERSION,
|
||||||
|
"data": {
|
||||||
|
"raw_kobo_store_token": self.raw_kobo_store_token,
|
||||||
|
"books_last_modified": to_epoch_timestamp(self.books_last_modified),
|
||||||
|
"books_last_created": to_epoch_timestamp(self.books_last_created),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return b64encode_json(token)
|
|
@ -35,4 +35,9 @@ except ImportError as err:
|
||||||
log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err)
|
log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err)
|
||||||
ldap = None
|
ldap = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import SyncToken as SyncToken
|
||||||
|
kobo = True
|
||||||
|
except ImportError as err:
|
||||||
|
log.debug("cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
|
||||||
|
kobo = None
|
||||||
|
|
|
@ -228,6 +228,41 @@ $(function() {
|
||||||
$(this).find(".modal-body").html("...");
|
$(this).find(".modal-body").html("...");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#modal_kobo_token")
|
||||||
|
.on("show.bs.modal", function(e) {
|
||||||
|
var $modalBody = $(this).find(".modal-body");
|
||||||
|
|
||||||
|
// Prevent static assets from loading multiple times
|
||||||
|
var useCache = function(options) {
|
||||||
|
options.async = true;
|
||||||
|
options.cache = true;
|
||||||
|
};
|
||||||
|
preFilters.add(useCache);
|
||||||
|
|
||||||
|
$.get(e.relatedTarget.href).done(function(content) {
|
||||||
|
$modalBody.html(content);
|
||||||
|
preFilters.remove(useCache);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on("hidden.bs.modal", function() {
|
||||||
|
$(this).find(".modal-body").html("...");
|
||||||
|
$("#config_delete_kobo_token").show();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btndeletetoken").click(function() {
|
||||||
|
//get data-id attribute of the clicked element
|
||||||
|
var pathname = document.getElementsByTagName("script"), src = pathname[pathname.length-1].src;
|
||||||
|
var path = src.substring(0,src.lastIndexOf("/"));
|
||||||
|
// var domainId = $(this).value("domainId");
|
||||||
|
$.ajax({
|
||||||
|
method:"get",
|
||||||
|
url: path + "/../../kobo_auth/deleteauthtoken/" + this.value,
|
||||||
|
});
|
||||||
|
$("#modalDeleteToken").modal("hide");
|
||||||
|
$("#config_delete_kobo_token").hide();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
$(window).resize(function() {
|
$(window).resize(function() {
|
||||||
$(".discover .row").isotope("layout");
|
$(".discover .row").isotope("layout");
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{% extends "layout.html" %}
|
{% extends "fragment.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="well">
|
<div class="well">
|
||||||
<h2 style="margin-top: 0">{{_('Generate Kobo Auth URL')}}</h2>
|
|
||||||
<p>
|
<p>
|
||||||
{{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}</a>.
|
{{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -59,6 +59,13 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if feature_support['kobo'] %}
|
||||||
|
<label>{{ _('Kobo Sync Token')}}</label>
|
||||||
|
<div class="form-group col">
|
||||||
|
<a class="btn btn-default" id="config_create_kobo_token" data-toggle="modal" data-target="#modal_kobo_token" data-remote="false" href="{{ url_for('kobo_auth.generate_auth_token', user_id=content.id) }}">{{_('Create/View')}}</a>
|
||||||
|
<div class="btn btn-danger" id="config_delete_kobo_token" data-toggle="modal" data-target="#modalDeleteToken" data-remote="false" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Delete')}}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
{% for element in sidebar %}
|
{% for element in sidebar %}
|
||||||
{% if element['config_show'] %}
|
{% if element['config_show'] %}
|
||||||
|
@ -146,6 +153,35 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal fade" id="modal_kobo_token" tabindex="-1" role="dialog" aria-labelledby="kobo_tokenModalLabel">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||||
|
<h4 class="modal-title" id="kobo_tokenModalLabel">{{_('Generate Kobo Auth URL')}}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">...</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modalDeleteToken" class="modal fade" role="dialog">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger">
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<p>{{_('Do you really want to delete the Kobo Token?')}}</p>
|
||||||
|
<button type="button" class="btn btn-danger" id="btndeletetoken" value="{{content.id}}">{{_('Delete')}}</button>
|
||||||
|
<button type="button" class="btn btn-default" id="btncancel" data-dismiss="modal">{{_('Back')}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block modal %}
|
{% block modal %}
|
||||||
{{ restrict_modal() }}
|
{{ restrict_modal() }}
|
||||||
|
|
11
cps/ub.py
11
cps/ub.py
|
@ -199,6 +199,7 @@ class User(UserBase, Base):
|
||||||
allowed_tags = Column(String, default="")
|
allowed_tags = Column(String, default="")
|
||||||
restricted_column_value = Column(String, default="")
|
restricted_column_value = Column(String, default="")
|
||||||
allowed_column_value = Column(String, default="")
|
allowed_column_value = Column(String, default="")
|
||||||
|
remote_auth_token = relationship('RemoteAuthToken', backref='user', lazy='dynamic')
|
||||||
|
|
||||||
|
|
||||||
if oauth_support:
|
if oauth_support:
|
||||||
|
@ -333,6 +334,7 @@ class RemoteAuthToken(Base):
|
||||||
user_id = Column(Integer, ForeignKey('user.id'))
|
user_id = Column(Integer, ForeignKey('user.id'))
|
||||||
verified = Column(Boolean, default=False)
|
verified = Column(Boolean, default=False)
|
||||||
expiration = Column(DateTime)
|
expiration = Column(DateTime)
|
||||||
|
token_type = Column(Integer, default=0)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
|
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
|
||||||
|
@ -364,6 +366,15 @@ def migrate_Database(session):
|
||||||
conn.execute("ALTER TABLE registration ADD column 'allow' INTEGER")
|
conn.execute("ALTER TABLE registration ADD column 'allow' INTEGER")
|
||||||
conn.execute("update registration set 'allow' = 1")
|
conn.execute("update registration set 'allow' = 1")
|
||||||
session.commit()
|
session.commit()
|
||||||
|
try:
|
||||||
|
session.query(exists().where(RemoteAuthToken.token_type)).scalar()
|
||||||
|
session.commit()
|
||||||
|
except exc.OperationalError: # Database is not compatible, some columns are missing
|
||||||
|
conn = engine.connect()
|
||||||
|
conn.execute("ALTER TABLE remote_auth_token ADD column 'token_type' INTEGER DEFAULT 0")
|
||||||
|
conn.execute("update remote_auth_token set 'token_type' = 0")
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# Handle table exists, but no content
|
# Handle table exists, but no content
|
||||||
cnt = session.query(Registration).count()
|
cnt = session.query(Registration).count()
|
||||||
if not cnt:
|
if not cnt:
|
||||||
|
|
|
@ -55,7 +55,8 @@ from .redirect import redirect_back
|
||||||
|
|
||||||
feature_support = {
|
feature_support = {
|
||||||
'ldap': False, # bool(services.ldap),
|
'ldap': False, # bool(services.ldap),
|
||||||
'goodreads': bool(services.goodreads_support)
|
'goodreads': bool(services.goodreads_support),
|
||||||
|
'kobo': bool(services.kobo)
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -1319,6 +1320,7 @@ def profile():
|
||||||
flash(_(u"E-mail is not from valid domain"), category="error")
|
flash(_(u"E-mail is not from valid domain"), category="error")
|
||||||
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
|
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
|
||||||
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
|
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
|
||||||
|
feature_support=feature_support,
|
||||||
registered_oauth=oauth_check, oauth_status=oauth_status)
|
registered_oauth=oauth_check, oauth_status=oauth_status)
|
||||||
if "nickname" in to_save and to_save["nickname"] != current_user.nickname:
|
if "nickname" in to_save and to_save["nickname"] != current_user.nickname:
|
||||||
# Query User nickname, if not existing, change
|
# Query User nickname, if not existing, change
|
||||||
|
@ -1329,6 +1331,7 @@ def profile():
|
||||||
return render_title_template("user_edit.html",
|
return render_title_template("user_edit.html",
|
||||||
translations=translations,
|
translations=translations,
|
||||||
languages=languages,
|
languages=languages,
|
||||||
|
feature_support=feature_support,
|
||||||
new_user=0, content=current_user,
|
new_user=0, content=current_user,
|
||||||
downloads=downloads,
|
downloads=downloads,
|
||||||
registered_oauth=oauth_check,
|
registered_oauth=oauth_check,
|
||||||
|
@ -1360,13 +1363,13 @@ def profile():
|
||||||
flash(_(u"Found an existing account for this e-mail address."), category="error")
|
flash(_(u"Found an existing account for this e-mail address."), category="error")
|
||||||
log.debug(u"Found an existing account for this e-mail address.")
|
log.debug(u"Found an existing account for this e-mail address.")
|
||||||
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
|
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
|
||||||
translations=translations,
|
translations=translations, feature_support=feature_support,
|
||||||
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
|
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
|
||||||
registered_oauth=oauth_check, oauth_status=oauth_status)
|
registered_oauth=oauth_check, oauth_status=oauth_status)
|
||||||
flash(_(u"Profile updated"), category="success")
|
flash(_(u"Profile updated"), category="success")
|
||||||
log.debug(u"Profile updated")
|
log.debug(u"Profile updated")
|
||||||
return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages,
|
return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages,
|
||||||
content=current_user, downloads=downloads,
|
content=current_user, downloads=downloads, feature_support=feature_support,
|
||||||
title= _(u"%(name)s's profile", name=current_user.nickname),
|
title= _(u"%(name)s's profile", name=current_user.nickname),
|
||||||
page="me", registered_oauth=oauth_check, oauth_status=oauth_status)
|
page="me", registered_oauth=oauth_check, oauth_status=oauth_status)
|
||||||
|
|
||||||
|
|
|
@ -32,3 +32,6 @@ rarfile>=2.7
|
||||||
# other
|
# other
|
||||||
natsort>=2.2.0
|
natsort>=2.2.0
|
||||||
git+https://github.com/OzzieIsaacs/comicapi.git@5346716578b2843f54d522f44d01bc8d25001d24#egg=comicapi
|
git+https://github.com/OzzieIsaacs/comicapi.git@5346716578b2843f54d522f44d01bc8d25001d24#egg=comicapi
|
||||||
|
|
||||||
|
#kobo integration
|
||||||
|
jsonschema>=3.2.0
|
||||||
|
|
|
@ -13,4 +13,3 @@ SQLAlchemy>=1.1.0
|
||||||
tornado>=4.1
|
tornado>=4.1
|
||||||
Wand>=0.4.4
|
Wand>=0.4.4
|
||||||
unidecode>=0.04.19
|
unidecode>=0.04.19
|
||||||
jsonschema>=3.2.0
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user